تخطَّ إلى المحتوى

تحليل تقني لاستغلال QBridge

هذا تحليل تقني شامل للاستغلال بناءً على الأدلة المسجلة على السلسلة، والكود المصدري للعقود، ولقطات الشاشة التي تم التقاطها وقت الهجوم. التحليل الأصلي باللغة الصينية من مجتمع الضحايا؛ تُرجم ووُسّع هنا مع جميع البيانات المستخرجة.


الدورالعنوان
المهاجم0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7
عقد QBridge (Ethereum)0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
معالج QBridge (BSC)0x4d8ae68fcae98bf93299548545933c0d273ba23a
معرّف مورد ETH0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01

الخطوة 1 — لم تكن BSC مسرح الجريمة الأول

Section titled “الخطوة 1 — لم تكن BSC مسرح الجريمة الأول”

عندما فحص المحققون عنوان المهاجم على BSC لأول مرة، كان هناك شيء خاطئ على الفور:

BSCScan يظهر معاملات اقتراض QubitFin Exploiter — لا مرحلة تحضيرية

انتقل المهاجم مباشرة إلى borrow() — بدون تحضير، بدون قرض فوري، بدون نشر عقد. هذا يعني أن BSC لم تكن نقطة بداية الهجوم. كان المهاجم يمتلك بالفعل رموز qXETH قبل أن يلمس بروتوكول الإقراض.

تتبع تلك الرموز qXETH كشف أنها سُكّت بواسطة مُرحّل الجسر — مما يعني أن المصدر كان Ethereum.


الخطوة 2 — معاملة voteProposal على BSC

Section titled “الخطوة 2 — معاملة voteProposal على BSC”

إحدى معاملات سك qXETH:

المعاملة: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

معاملة voteProposal على BSCScan — تظهر سك xETH من العنوان الصفري إلى المهاجم

الملاحظات الرئيسية من هذه المعاملة:

  • استدعت voteProposal(uint8 chainID, uint64 depositNonce, bytes32 resourceID, bytes data) على عقد QBridge على BSC
  • حدثت 3 عمليات نقل رموز:
    • العنوان الصفري → 0xa6b1bb... — 59,900 Cross-Chain xETH (سُكّت)
    • 0xa6b1bb...0xfd7a55... — 59,900 xETH (نُقلت عبر الجسر)
    • العنوان الصفري → QubitFin Exploiter — 59,900 qXETH (سُكّت كضمان)
  • القيمة: 0 BNB — لم يتم إيداع أي ETH حقيقي في أي مكان

دالة voteProposal كانت قابلة للاستدعاء فقط من قبل المُرحّل. استدعاها المُرحّل لأنه رأى حدث Deposit على Ethereum. ذلك الحدث كان مزيفاً.


الخطوة 3 — إيداعات المهاجم على Ethereum

Section titled “الخطوة 3 — إيداعات المهاجم على Ethereum”

Etherscan يظهر استدعاءات Deposit متعددة بقيمة صفرية من المهاجم إلى QBridge

أجرى المهاجم استدعاءات متعددة لـ deposit() على عقد QBridge على Ethereum (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6):

هاش المعاملةالكتلةالقيمة
0x3dfa33b5...140902340 ETH
0x58020654...140902300 ETH
0x501f8541...140902300 ETH
0xa6282e60...140902230 ETH
0x94031569...140902160 ETH
0xbdedb13d...140902100 ETH
0xeb9f622e...140902010 ETH

كل معاملة كانت بقيمة 0 ETH. الطريقة: deposit. كل واحدة أطلقت حدث Deposit قام المُرحّل بمعالجته بأمانة.

إحدى هذه المعاملات بالتفصيل:

تفاصيل Etherscan لأحد استدعاءات deposit() — 0 Ether، استدعاء deposit() مع معرّف مورد ETH

  • الكتلة: 14090216
  • الطابع الزمني: 27 يناير 2022، الساعة 09:45:32 مساءً بالتوقيت العالمي
  • من: 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 (المهاجم)
  • إلى: 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge على Ethereum)
  • القيمة: 0 Ether ($0.00)
  • الدالة المستدعاة: deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data)
  • سعر ETH وقت الهجوم: $2,425.83

الخطوة 4 — دالتان، حدث واحد

Section titled “الخطوة 4 — دالتان، حدث واحد”

كان عقد QBridge يحتوي على دالتين للإيداع:

كود عقد QBridge يظهر أن deposit() و depositETH() يطلقان نفس حدث Deposit

  • depositETH() — المسار الصحيح لـ ETH، يتطلب msg.value > 0
  • deposit() — مصمم لرموز ERC-20، لا يتطلب ETH

كلاهما أطلق نفس نوع حدث Deposit بالضبط. كان المُرحّل يستمع لأحداث Deposit ولم يكن قادراً على التمييز بين الدالة التي أطلقتها.


الخطوة 5 — كود المعالج والقائمة البيضاء

Section titled “الخطوة 5 — كود المعالج والقائمة البيضاء”

كود QBridgeHandler يظهر دالة deposit() مع فحص القائمة البيضاء في السطر 128 و safeTransferFrom في السطر 135

دالة QBridgeHandler.deposit():

function deposit(
bytes32 resourceID,
address depositer,
bytes calldata data
) external override {
address tokenAddress = _resourceIDToTokenContractAddress[resourceID]; // get token address
require(_contractWhitelist[tokenAddress], "not whitelisted"); // line 128: whitelist check
// ...
ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount); // line 135
}

بالنسبة لـ resourceID الخاص بـ ETH، أعاد العقد tokenAddress = 0x0000000000000000000000000000000000000000 (العنوان الصفري).


الخطوة 6 — العنوان الصفري كان على القائمة البيضاء

Section titled “الخطوة 6 — العنوان الصفري كان على القائمة البيضاء”

استعلام العقد يظهر أن العنوان الصفري موجود في القائمة البيضاء

استعلم المحققون من العقد: هل كان العنوان الصفري مدرجاً في القائمة البيضاء؟

نعم. كان كذلك.

كان هذا ضرورياً لأن depositETH() استخدمت أيضاً نفس آلية القائمة البيضاء مع العنوان الصفري كعنصر نائب لـ ETH. لكن هذا خلق أثراً جانبياً قاتلاً: العنوان الصفري اجتاز فحص القائمة البيضاء في deposit() أيضاً.

العقد يظهر أن العنوان الصفري مربوط بمعرّف مورد ETH

كان معرّف مورد ETH مربوطاً بـ 0x0000000000000000000000000000000000000000 — العنوان الصفري — كما تأكد من خلال استعلام resourceIDToTokenContractAddress.


الخطوة 7 — استدعاء EOA ينجح بصمت

Section titled “الخطوة 7 — استدعاء EOA ينجح بصمت”

مع tokenAddress = 0x0000...0000، نفّذ المعالج:

ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount);

العنوان الصفري هو EOA — حساب مملوك خارجياً بدون كود عقد.

في EVM: استدعاء أي دالة على عنوان بدون كود عقد ينجح بصمت — بدون تراجع، بدون خطأ، بدون تنفيذ فعلي.

استدعاء safeTransferFrom “نجح.” لم يتحرك شيء. لا ETH، لا رموز. لكن الكود استمر كما لو أن كل شيء على ما يرام — وأطلق حدث Deposit شرعياً.

خدعة EOA لبروتوكول 0x الموثقة في 2019

هذه الخدعة بالضبط تم توثيقها علنياً في تحديث أمني لبروتوكول 0x في 2019. نشرت Mound Inc. جسرها في 2022 دون مراعاة ذلك.


الخطوة 8 — دالة الاقتراض كانت صحيحة، لكن بعد فوات الأوان

Section titled “الخطوة 8 — دالة الاقتراض كانت صحيحة، لكن بعد فوات الأوان”

دالة borrow() في Qore — على نمط Compound، فحص borrowAllowed صحيح

function borrow(address qToken, uint amount) external override onlyListedMarket(qToken) nonReentrant {
_enterMarket(qToken, msg.sender);
require(IQValidator(qValidator).borrowAllowed(qToken, msg.sender, amount), "Qore: cannot borrow");
IQToken(payable(qToken)).borrow(msg.sender, amount);
qDistributor.notifyBorrowUpdated(qToken, msg.sender);
}

عقد الإقراض كان صحيحاًborrowAllowed() فحصت قيمة الضمان بشكل سليم. لكن رموز qXETH بدت شرعية على السلسلة. كان الاحتيال قد حدث بالفعل عند الجسر. بحلول وقت تشغيل borrow()، كان المهاجم يمتلك ضماناً يبدو حقيقياً لكنه غير مدعوم بأي شيء.


الخطوة 9 — الدليل الدامغ: تغيير المعاملات قبل الهجوم

Section titled “الخطوة 9 — الدليل الدامغ: تغيير المعاملات قبل الهجوم”

هذا هو التفصيل الذي لم يُفسَّر أبداً.

معاملة Ethereum تظهر إيداع WETH عبر دالة deposit() في 1 ديسمبر 2021 — قبل تغيير resourceID

قبل الهجوم، فحص أحد المحققين تاريخ دالة deposit(). ووجد:

1 ديسمبر 2021 — معاملة شرعية (0xc85df3fbfee7a8541e9fd354e98df78dca21ac6be40b929ff5da52ea8eab80de):

  • الكتلة: 13719888
  • من: 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
  • إلى: عقد QBridge 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
  • الرموز المنقولة: 0.1 WETH ($241.81 في ذلك الوقت)
  • الدالة: deposit() مع نفس resourceID

في ديسمبر 2021، نقلت deposit() مع ذلك resourceID WETH حقيقي — لأن resourceID كان مربوطاً بعنوان عقد WETH الحقيقي.

في وقت ما بين ديسمبر 2021 و28 يناير 2022 — تم استدعاء دالة مقصورة على المالك لإعادة تعيين ربط resourceID من عنوان عقد WETH إلى العنوان الصفري.

الكود يؤكد أن معامل resourceID تم تغييره بواسطة دالة المالك

فقط مالك العقد يمكنه إجراء هذا التغيير. لم يتطلب قفلاً زمنياً. لم يكن هناك إعلان. هذا التغيير الواحد غير المرئي في المعاملات هو ما حوّل جسراً عاملاً إلى جسر قابل للاستغلال.


الملخص: سبعة إخفاقات متزامنة

Section titled “الملخص: سبعة إخفاقات متزامنة”
الإخفاقالأثر
deposit() و depositETH() يطلقان نفس الحدثالمُرحّل لا يستطيع التمييز بين الإيداعات الحقيقية والمزيفة
عدم مراعاة النجاح الصامت لـ EOAsafeTransferFrom على العنوان الصفري تمر بدون نقل
العنوان الصفري مدرج في القائمة البيضاءاستدعاء EOA يتجاوز فحص القائمة البيضاء
نشر إنتاجي بدون تدقيقلم يكتشف أي مراجع خارجي أياً مما سبق
لا قفل زمني على دوال المالكresourceID أُعيد تعيينه بصمت وفوراً وبشكل غير مرئي
resourceID أُعيد تعيينه قبل الهجومالتغيير الذي جعل الاستغلال ممكناً
مُرحّل أعمى يثق بالأحداثخدمة خارج السلسلة تسك رموزاً دون التحقق من القيمة على السلسلة


معاملة الدليل الدامغ — استدعاء setResource() في 13 ديسمبر 2021

Section titled “معاملة الدليل الدامغ — استدعاء setResource() في 13 ديسمبر 2021”

هذا هو الدليل الأكثر أهمية.

640_7.jpg — استدعاء setResource() من المالك في 13 ديسمبر 2021 لإعادة تعيين معرّف مورد ETH إلى العنوان الصفري

المعاملة: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

الحقلالقيمة
الحالة✅ ناجحة
الكتلة13797391
الطابع الزمني13 ديسمبر 2021، الساعة 02:31:21 مساءً بالتوقيت العالمي
من0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
إلى0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge، Ethereum)
القيمة0 ETH
الدالةsetResource(address handlerAddress, bytes32 resourceID, address tokenAddress)

وسائط الإدخال المفكّكة:

#المعاملالقيمة
0handlerAddress0x17B7163cf1Dbb6286262ddc68b553D899B893f526
1resourceID0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01
2tokenAddress0x0000000000000000000000000000000000000000

setResource() هي دالة onlyOwner. فقط فريق Mound Inc. يمكنه استدعاؤها.

في 13 ديسمبر 2021 — قبل 45 يوماً من الاختراق — غيّر مالك العقد عمداً ربط tokenAddress لمعرّف مورد ETH إلى العنوان الصفري. قبل هذا الاستدعاء، كان نفس معرّف المورد يشير إلى عقد رمز WETH. بعد هذا الاستدعاء، أصبح يشير إلى لا شيء — مما مكّن استغلال النجاح الصامت لـ EOA.

كود voteProposal السابق الذي يُظهر أن المُرحّلين فقط يمكنهم استدعاؤه:

640_1.jpg — دالة voteProposal: مُعدّل onlyRelayers مميّز

function voteProposal(uint8 originDomainID, uint64 depositNonce, bytes32 resourceID, bytes calldata data)
external onlyRelayers notPaused {
address handlerAddress = resourceIDToHandlerAddress[resourceID];
require(handlerAddress != address(0), "QBridge: invalid handler");
// ...
if (proposal._status == ProposalStatus.Passed) {
executeProposal(originDomainID, depositNonce, resourceID, data, true);
return;
}
// ...
if (proposal._status == ProposalStatus.Inactive) {
proposal = Proposal({
status: ProposalStatus.Active,
_yesVotes: 0,
_yesVotesTotal: 0,
_proposedBlock: uint40(block.number)
});

وكود setResource() الذي غيّر كل شيء:

640_7.jpg — دالة setResource() المقصورة على المالك

function setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) external onlyOwner {
resourceIDToHandlerAddress[resourceID] = handlerAddress;
IQBridgeHandler(handlerAddress).setResource(resourceID, tokenAddress);
}

تسلسل سك qXETH (تحويلات الرموز على BSC)

Section titled “تسلسل سك qXETH (تحويلات الرموز على BSC)”

640_8.jpg — QubitFin Exploiter يستلم qXETH مسكوكة من العنوان الصفري عبر جولات متعددة

استلم المهاجم جولات متعددة من qXETH مسكوكة مباشرة من العنوان الصفري:

هاش المعاملةالكميةالرمزالمصدر
0xd8bba15555...999qXETHالعنوان الصفري → المستغل
0xf6008ab482...499qXETHالعنوان الصفري → المستغل
0xcfa4379af6...140ETH (BEP-20)0xb4b778… → المستغل
0x61ca8bc28f...190qXETHالعنوان الصفري → المستغل
0x881a68c9c9...0.1qXETHالعنوان الصفري → المستغل
0x8c5877d1b6...0.1qXETHالعنوان الصفري → المستغل

كل عملية سك qXETH من “العنوان الصفري → المستغل” تقابل استدعاء deposit() مزيف على Ethereum.