مرت على فريق منصة العمل الحر أربعة مقالات: فهموا كيف يعمل Ethereum، وكتبوا عقد الضمان، واختبروه بالثوابت، وتعلموا الرموز. وفي كل مقال مر بهم خطأ كان سيكلف مالاً لو نشر: أحداث أطلقت بعد إرسال المال، ومفتاح إدارة يغير القواعد، وعقد تنفيذ لم يهيأ ملكه أول من استدعاه، ورموز علقت إلى الأبد. وكل واحد من هذه الأخطاء ليس حادثاً فردياً، وإنما نسخة من صنف معروف من الثغرات، له اسم وحوادث موثقة بمئات الملايين، وله إصلاح معروف أيضاً. ولذلك قرر الفريق قبل أن يقترب من أي مال حقيقي أن يدرس هذه الأصناف واحداً واحداً، لا ليتعلم كيف يهاجم، وإنما ليعرف كيف يدافع: ما الخطأ، وكيف يبدو في الكود، وما الذي حدث فعلاً عندما أهمله آخرون، وكيف يصلح.
وهذا المقال تعليمي تقني وموجه للدفاع فقط. كل مثال معطوب فيه عقد صغير جداً نكتبه ونشغله على شبكة تطوير محلية بحسابات اختبار ليس لها قيمة، وبجانبه النسخة المصححة مباشرة، والمهاجم فيه عقد اختبار يهاجم عقدنا نحن على جهازنا، والهدف أن ترى الثغرة تعمل لتفهم لماذا يجب أن تمنعها. وهو المقال الخامس في مرحلة Ethereum، ويبني على مقال العقود الذكية ومقال أدوات التطوير.
وسوف نناقش في هذا المقال ما يلي:
- كيف نرتب أصناف الثغرات، ولماذا نتعلم الهجوم من جهة الدفاع.
- إعادة الدخول Reentrancy: أشهر صنف، بتجربة كاملة يفرغ فيها المهاجم خزنة، ثم النسخة الآمنة والحارس، وحادثة The DAO.
- التحكم في الصلاحيات Access Control: دالة نسيت فحص المالك، وإصلاحها، وحادثة جسر Ronin.
- التلاعب بالأوراكل Price Oracle Manipulation: مقرض يقرأ سعراً لحظياً يسهل تحريكه، وحادثة Mango Markets.
- تجاوز سعة الأعداد الصحيحة Integer Overflow، والاستدعاء غير المفحوص Unchecked Call، وأصناف أخرى.
- التحليل الآلي مع المدقق المدمج وأداة Slither، ودفاع الطبقات الخمس، وقائمة تحقق للفريق.
لماذا نتعلم الهجوم من جهة الدفاع؟
قد يبدو غريباً أن يكتب الفريق عقوداً معطوبة ويهاجمها، ولكن هذا هو بالضبط ما تفعله المراجعة الأمنية الجادة، لأنك لا تستطيع أن تمنع ثغرة لا تفهم كيف تعمل. والفرق بين هذا وبين تعليم الهجوم أن كل ما سوف نهاجمه هو عقودنا نحن على شبكتنا المحلية، بمبالغ وهمية، وأن كل مثال معطوب يأتي بجانبه الإصلاح مباشرة، فالهدف هو السطر الذي يجب أن تكتبه، لا السطر الذي يجب أن تستغله. وهذه هي طريقة توثيق الأمان في ethereum.org نفسه، وطريقة قوائم الثغرات المعروفة.
ولتنظيم الأصناف نستخدم قائمة OWASP Smart Contract Top 10 لسنة 2025، وهي ترتب أشهر عشرة أصناف، أولها التحكم في الصلاحيات، ثم التلاعب بالأوراكل، ثم أخطاء المنطق، ثم نقص التحقق من المدخلات، ثم إعادة الدخول، ثم الاستدعاء غير المفحوص، ثم هجمات القروض السريعة، ثم تجاوز سعة الأعداد، ثم العشوائية غير الآمنة، ثم حجب الخدمة. وسوف نأخذ أربعة منها بتجارب كاملة، ونمر على الباقي بإيجاز، لأن الأربعة تغطي الأنماط الأساسية: استدعاء خارجي في وقت خاطئ، وفحص غائب، ومعلومة يثق بها العقد ويمكن تحريكها، وحساب خاطئ.
وقبل أن نبدأ، نقطة مهمة في عقلية الدفاع تختلف عن عقلية بناء التطبيقات العادية. في تطبيق الويب، المهاجم يحاول أن يفعل شيئاً لم تسمح به، فتكفيك قاعدة «امنع ما لم تسمح به صراحة». أما في العقد الذكي فالمهاجم لا يخرق الصلاحيات غالباً، وإنما يستخدم ما سمحت به أنت بطريقة لم تتوقعها: يستدعي دالة عامة في وقت لم تتخيله، أو يرسل قيمة اختبرتها بقيم صغيرة فأرسلها ضخمة، أو يجعل عقده يرد على استدعائك بشيء غير المتوقع. ولذلك السؤال الدفاعي ليس «هل منعت الممنوع؟» وإنما «ما أسوأ ما يستطيع أحد أن يفعله بكل ما سمحت به، وبأي ترتيب، وبأي قيمة؟» وهذا السؤال لا يجاب عنه بالقراءة وحدها، وإنما بأن تلعب أنت دور المهاجم على عقدك قبل أن يلعبه غيرك على مالك. وكل تجربة في هذا المقال هي بالضبط ذلك: نكتب المهاجم، ونشغله، ونرى ماذا يحدث، ثم نصلح.
نجهز مشروع Foundry في lab/sec كما في المقال السابق، بملف إعداد يربط مكتبة OpenZeppelin، ونشغل Anvil، ونحمل المكتبتين:
docker run -d --name lab-sec -p 127.0.0.1:8545:8545 -v "$PWD/lab:/lab" -w /lab \
ghcr.io/foundry-rs/foundry:v1.8.6 "anvil --host 0.0.0.0"
docker exec -w /lab/sec lab-sec forge install --no-git OpenZeppelin/[email protected]
docker exec -w /lab/sec lab-sec forge install --no-git foundry-rs/[email protected]الصنف الأول: إعادة الدخول Reentrancy
هذا الصنف هو الذي أسقط The DAO سنة 2016، وما زال من أكثر الثغرات تكراراً. وفكرته أن عقدك يستدعي عقداً خارجياً، ثم يكمل عمله بعد عودة الاستدعاء، ولكن العقد الخارجي يستطيع أثناء الاستدعاء أن يعيد الدخول إلى عقدك قبل أن يكمل، فيجد الحالة كما كانت قبل أن يحدثها. ولنرى ذلك نكتب خزنة بسيطة، مثل صندوق إكراميات مشترك يودع فيه كل مستقل ويسحب رصيده لاحقاً، في الملف src/Vault.sol، وفيه النسختان المعطوبة والآمنة معاً:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
/// A shared tip jar: each freelancer deposits, and withdraws their own balance later.
/// VulnerableVault sends the ether BEFORE zeroing the balance: the reentrancy class (The DAO, 2016).
contract VulnerableVault {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balance[msg.sender];
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}(""); // external call first
require(ok, "send failed");
balance[msg.sender] = 0; // state updated after: too late
}
}
/// The fix: checks, then effects (zero the balance), then the interaction. Same logic, safe order.
contract SafeVault {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balance[msg.sender];
require(amount > 0, "nothing to withdraw");
balance[msg.sender] = 0; // effects first
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction last
require(ok, "send failed");
}
}
/// The attacker contract: deposits 1 ether, then re-enters withdraw() from its receive().
contract Attacker {
address public target;
uint256 public hits;
constructor(address _target) { target = _target; }
function attack() external payable {
(bool ok1, ) = target.call{value: 1 ether}(abi.encodeWithSignature("deposit()"));
require(ok1, "deposit failed");
(bool ok2, ) = target.call(abi.encodeWithSignature("withdraw()"));
require(ok2, "withdraw failed");
}
receive() external payable {
hits++;
if (target.balance >= 1 ether) {
target.call(abi.encodeWithSignature("withdraw()"));
}
}
}الفرق بين النسختين سطران بترتيب معكوس. في المعطوبة نرسل المال أولاً بالأمر msg.sender.call{value: amount}("")، ثم نصفر الرصيد. وعندما يكون المستقبل عقد المهاجم، فإن إرسال المال يشغل دالة receive فيه، وهذه الدالة تعيد استدعاء withdraw قبل أن يصل السطر الذي يصفر الرصيد، فيجد الرصيد كما هو، فيسحب مرة أخرى، وهكذا. والنسخة الآمنة تصفر الرصيد قبل الإرسال، فعند إعادة الدخول يجد الرصيد صفراً فيرفض. وهذا النمط اسمه «تحقق ثم عدل ثم تواصل» Checks-Effects-Interactions، وقد ذكرناه في مقال العقود الذكية، والآن نراه يعمل.
نكتب اختباراً يلعب فيه عقد المهاجم دوره، في test/Reentrancy.t.sol، ويودع مستقلان أمينان خمسة ETH لكل منهما، ثم يهاجم:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {Test, console2} from "forge-std/Test.sol";
import {VulnerableVault, SafeVault, Attacker} from "../src/Vault.sol";
contract ReentrancyTest is Test {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address evil = makeAddr("evil");
function _seed(address vault) internal {
// two honest freelancers each deposit 5 ether
vm.deal(alice, 5 ether); vm.prank(alice);
(bool a,) = vault.call{value: 5 ether}(abi.encodeWithSignature("deposit()")); require(a);
vm.deal(bob, 5 ether); vm.prank(bob);
(bool b,) = vault.call{value: 5 ether}(abi.encodeWithSignature("deposit()")); require(b);
}
function test_Vulnerable_IsDrained() public {
VulnerableVault vault = new VulnerableVault();
_seed(address(vault));
Attacker attacker = new Attacker(address(vault));
vm.deal(evil, 1 ether);
vm.prank(evil);
attacker.attack{value: 1 ether}();
console2.log("attacker deposited 1, vault had 11, attacker withdrew (wei):", address(attacker).balance);
console2.log("re-entries:", attacker.hits());
console2.log("vault left (wei):", address(vault).balance);
assertGt(address(attacker).balance, 1 ether); // took more than it put in
}
function test_Safe_Resists() public {
SafeVault vault = new SafeVault();
_seed(address(vault));
Attacker attacker = new Attacker(address(vault));
vm.deal(evil, 1 ether);
vm.prank(evil);
attacker.attack{value: 1 ether}();
// the re-entrant withdraw finds a zero balance and reverts; the attacker only gets its own 1 back
console2.log("re-entries:", attacker.hits());
console2.log("attacker balance (wei):", address(attacker).balance);
console2.log("vault left (wei):", address(vault).balance);
assertEq(address(attacker).balance, 1 ether);
assertEq(address(vault).balance, 10 ether); // the two honest deposits are untouched
}
}ثم نشغله:
docker exec -w /lab/sec lab-sec forge test --match-contract Reentrancy -vvوالمخرج سوف يكون كما يلي:
Ran 2 tests for test/Reentrancy.t.sol:ReentrancyTest
[PASS] test_Safe_Resists() (gas: 218778)
Logs:
re-entries: 1
attacker balance (wei): 1000000000000000000
vault left (wei): 10000000000000000000
[PASS] test_Vulnerable_IsDrained() (gas: 302909)
Logs:
attacker deposited 1, vault had 11, attacker withdrew (wei): 11000000000000000000
re-entries: 11
vault left (wei): 0
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.62msفي المخرج أعلاه لاحظ التالي:
- في النسخة المعطوبة أودع المهاجم 1 ETH فقط، ثم خرج برصيد 11 ETH، أي كل ما في الخزنة: ودائعه هو وودائع المستقلين. وعداد إعادة الدخول
hitsيساوي 11، أي أن دالةreceiveفي عقده استدعتwithdrawإحدى عشرة مرة في معاملة واحدة قبل أن تفرغ الخزنة. والخزنة بقي فيها صفر. - في النسخة الآمنة، بنفس المهاجم بالضبط، خرج المهاجم برصيد 1 ETH فقط، أي وديعته هو، وبقي في الخزنة 10 ETH، أي ودائع المستقلين كاملة. وعداد إعادة الدخول 1، أي أن
receiveحاول الدخول مرة واحدة فوجد رصيده صفراً فرفض العقد السحب، فتوقف. - الاختباران نجحا، لأن اختبار النسخة المعطوبة يتوقع أن يسرق المهاجم، واختبار النسخة الآمنة يتوقع أنه لا يستطيع. وهذا هو المعنى العملي لاختبار الأمان: أن تكتب الهجوم كاختبار، وتتأكد أن الإصلاح يوقفه.
والصورة التالية تضع النسختين جنباً إلى جنب مع الأرقام:

والصورة التالية تفصل خطوات الهجوم في المعاملة الواحدة:

split في عقد The DAO، وهي نفس الثغرة التي رأيناها: استدعاء خارجي يسمح بإعادة الدخول قبل تحديث الحالة، فجمع المهاجم الأموال مراراً في نفس العملية. وكان الرد الذي اختاره المجتمع تفرعاً صلباً Hard Fork عند الكتلة 1,920,000 أعاد الأموال، واعترض جزء من المجتمع فاستمر على السلسلة القديمة التي صارت Ethereum Classic، كما رويناها في مقال البلوكتشين للمبتدئين. والبيان نفسه قال إن الثغرة تخص عقد The DAO وحده وأن Ethereum سليمة، وهذا هو الدرس: المشكلة ليست في الشبكة، وإنما في سطرين بترتيب خاطئ.وقد يتساءل البعض: لماذا يسمح Ethereum بإعادة الدخول أصلاً، أليس من الأفضل أن تمنع الشبكة عقداً من استدعاء عقد آخر يعيد استدعاءه؟ والإجابة أن القدرة على استدعاء العقود بعضها بعضاً هي أقوى ما في Ethereum، وهي التي تجعل العقود كقطع تركيب يبني بعضها على بعض Composability، كما ذكرنا في مقال العقود الذكية. فالشبكة لا تستطيع أن تمنع إعادة الدخول دون أن تمنع هذه القدرة، ولذلك تركت الأمر لمن يكتب العقد: أنت من يقرر متى تحدث حالتك، وقبل أي استدعاء خارجي. والمشكلة أن الترتيب الطبيعي الذي يكتبه المطور بلا تفكير، أي «احسب ثم أرسل ثم سجل»، هو بالضبط الترتيب الخطر، لأنه يؤجل التسجيل إلى ما بعد الإرسال. ولهذا السبب ما زال هذا الصنف يتكرر بعد عشر سنوات من The DAO رغم أنه أشهر ثغرة في المجال: لأنه يأتي من عادة برمجية سليمة في كل مكان آخر، ولا يصبح خطأ إلا هنا، حيث الاستدعاء الخارجي يعطي الطرف الآخر التحكم في منتصف دالتك.
ولاحظ تفصيلاً مهماً في عقد المهاجم: دالة receive فيه هي التي تنفذ عندما يصله المال، وهي التي تعيد الدخول. والمحفظة العادية لشخص حقيقي ليس فيها كود، فلا تعيد الدخول، ولهذا لا يظهر الخطر إلا عندما يكون المستقبل عقداً كتبه أحد لهذا الغرض. وهذا يخدع كثيراً من المطورين: يختبرون عقدهم بحسابات عادية فيعمل كل شيء، ولا تظهر الثغرة إلا عندما يأتي مهاجم بعقد مصمم. ولذلك لا يكفي أن تختبر المسار السعيد بحسابات أمينة، بل يجب أن تكتب المهاجم نفسه كما فعلنا، أو تستخدم الاختبار العشوائي والثوابت التي رأيناها في مقال أدوات التطوير، فالثابت «المال يخرج من الخزنة بقدر ما دخل» كان سيفشل مع العقد المعطوب في أول تسلسل يصل إليه.
إذا لم تكف إعادة الترتيب: الحارس
إعادة ترتيب السطرين تكفي أغلب الحالات، ولكن بعض العقود تستدعي عقوداً خارجية في أماكن متعددة، أو يصعب فيها ضمان الترتيب في كل دالة، أو توجد فيها إعادة دخول بين دالتين مختلفتين. ولهذه الحالات توجد طبقة ثانية: قفل يمنع أي دخول آخر إلى العقد أثناء تنفيذ الأول. ومكتبة OpenZeppelin توفره جاهزاً باسم ReentrancyGuard، وهذا عقد يستخدمه في src/GuardedVault.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// Same vault, protected by a lock that refuses a second entry while the first is still running.
contract GuardedVault is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balance[msg.sender];
require(amount > 0, "nothing to withdraw");
balance[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}الكلمة nonReentrant على الدالة تجعلها ترفع قفلاً عند الدخول وتنزله عند الخروج، وأي استدعاء يصل والقفل مرفوع يفشل. وجربنا نفس المهاجم عليه فخرج برصيده هو فقط، كما في النسخة الآمنة. والقاعدة: اجعل الترتيب الصحيح هو الأساس دائماً، وأضف الحارس طبقة ثانية في الدوال التي تحرك مالاً وتستدعي عقوداً خارجية، فالحارس وحده مع ترتيب خاطئ قد لا يكفي ضد إعادة الدخول بين دالتين.
الصنف الثاني: التحكم في الصلاحيات Access Control
هذا هو أول صنف في قائمة OWASP، وأبسطه: دالة حساسة تنسى أن تتحقق من هوية من يستدعيها. ورأينا نسخة منه في مقال العقود الذكية مع عقد Parity الذي لم يهيأ فملكه أول من استدعاه. والمثال التالي أوضح: خزينة منصة فيها دالة تسحب كل الرصيد، ونسيت فحص المالك، في src/AccessControl.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/// A platform treasury. The "withdraw everything" function forgot its access check (access control class).
contract VulnerableTreasury {
address public owner;
constructor() payable { owner = msg.sender; }
// BUG: no "only owner" check. Anyone can sweep the balance.
function withdrawAll(address to) external {
(bool ok, ) = to.call{value: address(this).balance}("");
require(ok, "send failed");
}
}
/// The fix: the same function, guarded by Ownable from a reviewed library.
contract SafeTreasury is Ownable {
constructor() payable Ownable(msg.sender) {}
function withdrawAll(address to) external onlyOwner {
(bool ok, ) = to.call{value: address(this).balance}("");
require(ok, "send failed");
}
}العقد المعطوب يحفظ المالك في المنشئ، ولكن الدالة withdrawAll لا تستعمله، فأي شخص يستطيع أن يستدعيها ويسحب الرصيد إلى عنوانه. والعقد الآمن يرث Ownable من المكتبة، ويضع الكلمة onlyOwner على الدالة، فترفض أي مستدع غير المالك. واختبرنا الحالتين في test/AccessAndOracle.t.sol، ونشغل جزء الصلاحيات:
docker exec -w /lab/sec lab-sec forge test --match-contract AccessControlTest -vvوالمخرج سوف يكون كما يلي:
Ran 2 tests for test/AccessAndOracle.t.sol:AccessControlTest
[PASS] test_Safe_OnlyOwner() (gas: 35631)
[PASS] test_Vulnerable_AnyoneSweeps() (gas: 48151)
Logs:
stranger balance (wei): 10000000000000000000في النسخة المعطوبة سحب غريب لا علاقة له بالمنصة كل الرصيد، 10 ETH، إلى عنوانه، ونجح الاختبار الذي يتوقع ذلك. وفي النسخة الآمنة رفض العقد الغريب بالخطأ OwnableUnauthorizedAccount، وبقي الرصيد كاملاً. والفرق سطر واحد: الكلمة onlyOwner. وهذا الصنف يشمل أكثر من نسيان الفحص: صلاحية تعطى لعنوان خاطئ، أو دالة تهيئة يستطيع أي شخص استدعاؤها، أو مالك واحد يملك كل شيء دون مهلة ولا توزيع، وكلها رأينا أمثلة عليها في مقال العقود الذكية.
وقد يبدو هذا الصنف سخيفاً، فمن ينسى فحص المالك على دالة تسحب كل المال؟ ولكنه في الواقع أول صنف في قائمة OWASP لأنه الأكثر حدوثاً، والسبب أن الصلاحيات في العقود الحقيقية ليست مالكاً واحداً ودالة واحدة، وإنما شبكة من الأدوار: من يصدر الرموز، ومن يوقف العقد، ومن يغير الإعدادات، ومن يرقي العقد، وكل دور على عدة دوال، وكل دالة يجب أن تحمل الفحص الصحيح. ويكفي أن تنسى الفحص على دالة واحدة من عشرات، أو أن تعطي دوراً لعنوان خطأ عند النشر، أو أن تضيف دالة جديدة في ترقية وتنسى حراستها، ليفتح الباب. ولهذا لا يعتمد الفريق على الانتباه، وإنما يستخدم نظام أدوار جاهزاً مثل AccessControl من OpenZeppelin، ويكتب اختباراً لكل دالة حساسة يتأكد أنها ترفض كل من ليس له الدور، فالاختبار يمسك ما ينساه الانتباه.
وهناك وجه آخر لهذا الصنف يخطئ فيه الفريق في الاتجاه المعاكس: أن تحرس الدالة جيداً، ولكن تجمع كل الصلاحيات في مفتاح واحد يملكه شخص واحد. فالعقد هنا آمن ضد الغرباء، ولكنه ليس آمناً ضد صاحب المفتاح نفسه، سواءً أساء استخدامه أو سرق منه. وهذا بالضبط ما رأيناه في مقال العقود الذكية عندما غيرنا الرسوم من 5% إلى 50% بمعاملة واحدة من المالك. ولذلك حراسة الصلاحية الكاملة تتطلب شيئين: أن ترفض الدالة الغرباء، وأن يكون المفتاح نفسه موزعاً، أي محفظة متعددة التوقيع تتطلب عدة أشخاص لأي إجراء خطير، مع مهلة زمنية تعطي المستخدمين وقتاً للخروج قبل أن ينفذ أي تغيير.
onlyOwner، ولذلك أوصينا في مقال العقود الذكية بمحفظة متعددة التوقيع ومهلة زمنية.الصنف الثالث: التلاعب بالأوراكل Price Oracle Manipulation
في مقال الرموز قلنا إن العقد لا يرى العالم الخارجي، ويحتاج أوراكل يكتب له المعلومة. والصنف الثاني في قائمة OWASP هو أن يثق العقد بمصدر سعر يمكن تحريكه. والمثال الأشهر مقرض يقرأ السعر من مجمع Pool صغير يعمل بقاعدة حاصل الضرب الثابت x · y = k، وهذه القاعدة تجعل السعر اللحظي يتحرك بشدة مع أي صفقة كبيرة. نكتب المجمع والمقرض في src/Oracle.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
interface IPool { function spotPrice() external view returns (uint256); }
/// A tiny constant-product pool (x * y = k) whose instantaneous price is easy to move with a big trade.
contract MiniPool is IPool {
uint256 public reserveToken; // the project token
uint256 public reserveUsd; // a stable unit
constructor(uint256 t, uint256 u) { reserveToken = t; reserveUsd = u; }
/// price of 1 token in USD units, scaled by 1e18
function spotPrice() external view returns (uint256) {
return reserveUsd * 1e18 / reserveToken;
}
/// buy tokens with USD: moves the price up (fewer tokens left, more USD in)
function buy(uint256 usdIn) external returns (uint256 tokensOut) {
uint256 k = reserveToken * reserveUsd;
uint256 newUsd = reserveUsd + usdIn;
uint256 newToken = k / newUsd;
tokensOut = reserveToken - newToken;
reserveToken = newToken;
reserveUsd = newUsd;
}
}
/// A lender that reads the pool's SPOT price to value collateral. This is the oracle-manipulation class.
contract VulnerableLender {
IPool public pool;
constructor(IPool _pool) { pool = _pool; }
/// how much USD you may borrow against `tokenCollateral` tokens, at the current spot price
function borrowable(uint256 tokenCollateral) external view returns (uint256) {
return tokenCollateral * pool.spotPrice() / 1e18;
}
}المقرض يحسب كم يستطيع المستخدم أن يقترض مقابل ضمانه من الرموز بالسعر اللحظي الذي يقرأه من المجمع. والمهاجم يشتري كمية كبيرة من الرموز في معاملة واحدة، فيرتفع السعر اللحظي بشدة، فيصبح ضمانه يساوي أضعاف قيمته الحقيقية، فيقترض بناءً عليه. نشغل الاختبار:
docker exec -w /lab/sec lab-sec forge test --match-contract OracleTest -vvوالمخرج سوف يكون كما يلي:
Ran 1 test for test/AccessAndOracle.t.sol:OracleTest
[PASS] test_SpotPriceIsManipulable() (gas: 96134)
Logs:
price before (1e18): 1000000000000000000
borrowable on 100k tokens before: 100000
price after (1e18): 25000000000000000000
borrowable on 100k tokens after: 2500000قبل الهجوم كان سعر الرمز 1.0، والاقتراض على 100 ألف رمز 100 ألف وحدة. وبعد أن اشترى المهاجم في معاملة واحدة أصبح السعر 25.0، أي خمسة وعشرين ضعفاً، فأصبح نفس الضمان يقترض 2.5 مليون. والإصلاح ألا يعتمد العقد على سعر لحظة واحدة من مجمع واحد يمكن تحريكه: يستخدم سعراً متوسطاً على مدى زمني TWAP يصعب تحريكه في معاملة واحدة، أو عدة مصادر مستقلة، أو مغذي أسعار مصمم لهذا مثل Chainlink. والقاعدة الأعم: كل معلومة يبني عليها العقد قراراً مالياً، اسأل كم يكلف تزويرها، كما قلنا في مقال العقود الذكية إن أمان العقد بقدر أمان مصدره.
ولماذا يصعب تحريك المتوسط الزمني TWAP بينما يسهل تحريك السعر اللحظي؟ لأن السعر اللحظي قيمة واحدة في لحظة واحدة، فمعاملة واحدة ضخمة تغيرها، أما المتوسط الزمني فهو سعر محسوب على فترة ماضية، مثل آخر نصف ساعة، فلكي تحركه يجب أن تبقي السعر منحرفاً طوال تلك الفترة، وهذا يعني أن تخاطر برأس مالك مكشوفاً زمناً طويلاً يستطيع فيه غيرك أن يتاجر ضدك ويعيد السعر ويأخذ خسارتك. فالمتوسط الزمني لا يمنع التلاعب منعاً مطلقاً، وإنما يرفع تكلفته من معاملة واحدة رخيصة إلى مخاطرة طويلة مكلفة، وهذا هو جوهر الأمان في العقود: لا يوجد مستحيل، وإنما مكلف بقدر يكفي للردع. ولاحظ أن الخطر هنا ليس في المجمع نفسه، فالمجمع يفعل ما صمم له، وإنما في المقرض الذي قرأ منه سعراً لم يصمم ليكون مصدر أسعار موثوقاً. وهذا درس عام: الثغرة قد لا تكون في عقدك ولا في العقد الذي تقرأ منه، وإنما في الثقة التي وضعتها بينهما.

الصنف الرابع: تجاوز السعة والاستدعاء غير المفحوص
نجمع هنا صنفين يرتبطان بالحساب والاستدعاء. الأول تجاوز سعة الأعداد الصحيحة Integer Overflow: الأرقام في آلة EVM بطول ثابت 256 بت، فإذا تجاوزت العملية هذا الطول التفت القيمة، مثل عداد يعود إلى الصفر بعد أكبر رقم. وقبل إصدار Solidity 0.8 كان هذا يحدث بصمت، أما منذ 0.8 فالمترجم يتحقق من كل عملية ويوقف المعاملة عند التجاوز. نكتب دالة دفع بالدفعات في src/Overflow.sol، بنسخة عادية محمية وأخرى تعيد السلوك القديم في كتلة unchecked:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
/// The batchOverflow class (BEC token, CVE-2018-10299, April 2018): amount * count overflowed
/// a 256-bit integer on old compilers, minting value from nothing.
contract BatchPay {
mapping(address => uint256) public balance;
constructor() { balance[msg.sender] = 1000; }
/// Solidity 0.8 checks arithmetic by default: this reverts on overflow instead of wrapping.
function batchTransfer(address[] calldata to, uint256 amount) external {
uint256 total = amount * to.length; // checked: reverts if it overflows
require(balance[msg.sender] >= total, "insufficient");
balance[msg.sender] -= total;
for (uint256 i = 0; i < to.length; i++) balance[to[i]] += amount;
}
/// The old, pre-0.8 behaviour, reproduced on purpose inside an unchecked block.
function batchTransferUnchecked(address[] calldata to, uint256 amount) external {
unchecked {
uint256 total = amount * to.length; // wraps around silently, as before 0.8
require(balance[msg.sender] >= total, "insufficient");
balance[msg.sender] -= total;
for (uint256 i = 0; i < to.length; i++) balance[to[i]] += amount;
}
}
}الفكرة أن المهاجم يختار مبلغاً كبيراً جداً وعدد مستقبلين بحيث يتجاوز حاصل ضربهما سعة 256 بت فيلتف إلى صفر، فيمر فحص الرصيد لأن المطلوب صار صفراً، ثم يضاف المبلغ الكبير لكل مستقبل. نشغل الاختبار:
docker exec -w /lab/sec lab-sec forge test --match-contract OverflowTest -vvوالمخرج سوف يكون كما يلي:
Ran 2 tests for test/Overflow.t.sol:OverflowTest
[PASS] test_ModernCompilerReverts() (gas: 28855)
[PASS] test_UncheckedWraps_MintsFromNothing() (gas: 104585)
Logs:
sender still has: 1000
addr(1) now has (as 1e76): 5في الدالة العادية يوقف المترجم المعاملة عند التجاوز بما يسمى Panic برمز 0x11، فلا يحدث شيء. وفي الدالة التي أعادت السلوك القديم داخل unchecked التف حاصل الضرب إلى صفر، فمر فحص الرصيد، وبقي رصيد المرسل 1000 لم ينقص، بينما أصبح رصيد أول مستقبل رقماً هائلاً، قرابة خمسة في عشرة أس ستة وسبعين. فالمهاجم أنشأ ثروة من لا شيء دون أن يدفع شيئاً. والإصلاح أنك تستخدم Solidity 0.8 أو أحدث ولا تضع العمليات في unchecked إلا إذا أثبت أنها لا تتجاوز، فالحماية اليوم تأتي من المترجم نفسه.
batchTransfer سمح لمهاجمين بزيادة أصولهم الرقمية دون إذن بتمرير مستقبلين مع قيمة كبيرة، وعرف باسم batchOverflow. والعقد كان مكتوباً بمترجم قديم لا يتحقق من الحساب، فأنشأ المهاجم كميات هائلة من الرمز من لا شيء. وهذا سبب أن تثبيت إصدار مترجم حديث، كما نفعل في كل تجاربنا، ليس تفصيلاً شكلياً، وإنما حماية من صنف كامل من الثغرات.والصنف الثاني هو الاستدعاء غير المفحوص Unchecked Call. فالدالتان القديمتان send وtransfer لإرسال المال تمرران كمية غاز ثابتة صغيرة هي 2300 وحدة، وهذا يكفي محفظة عادية ولكن قد لا يكفي عقداً يحتاج عملاً عند الاستلام، فيفشل الإرسال. والمشكلة أن send تعيد قيمة تقول هل نجحت، فإذا لم يفحصها العقد ظن أنه دفع وأكمل. ولهذا استخدمنا في كل عقودنا الأمر call مع فحص القيمة العائدة، كما يوصي توثيق Solidity للاعتبارات الأمنية.
send الذي يمرر 2300 غاز فقط، فلم يكف ذلك محافظ Mist القائمة على عقود، فعاد المال إلى عقد اللعبة، والعقد لم يفحص نتيجة الإرسال فظن أنه دفع وأكمل، فلم تصل بعض مدفوعات الملوك واللاعبين. والدرس في التقرير نفسه: افحص دائماً القيمة العائدة من send وcall وتصرف بناءً عليها، والأفضل أن يسحب المستفيد ماله بنفسه بنمط السحب Pull بدل أن يرسل العقد لكثيرين في حلقة، لأن مستقبلاً واحداً معطلاً قد يوقف الجميع.
أصناف أخرى يجب أن تعرفها
بقيت أصناف في قائمة OWASP مررنا على بعضها في المقالات السابقة، ونجمعها هنا:
- القروض السريعة Flash Loans: استعارة رأس مال ضخم يرد في نفس المعاملة، وهي ليست ثغرة بذاتها، ولكنها تضخم غيرها، فبها نفذ مهاجم Mango Markets تحريك السعر دون أن يملك المال أصلاً.
- أخطاء المنطق Logic Errors: الكود يعمل كما كتب، ولكن ما كتب خطأ، مثل أن يصرف العقد قبل التسليم، وهذه لا يجدها مدقق آلي لأنها في فهم المنطق، وتحتاج اختبار ثوابت ومراجعة بشرية كما رأينا في مقال أدوات التطوير.
- نقص التحقق من المدخلات: مثل عنوان صفري بلا فحص، وهو ما نبهنا إليه المدقق الآلي في عقد الضمان.
- العشوائية غير الآمنة Insecure Randomness: استخدام
block.timestampأو هاش الكتلة كمصدر عشوائية في قرعة أو توزيع، وكلاهما يستطيع مقترح الكتلة أن يؤثر فيه ضمن هامش، فلا يصلح لأي قيمة حقيقية. - حجب الخدمة Denial of Service: حلقة على بيانات يتحكم فيها المستخدم تكبر حتى تتجاوز حد الغاز فتجمد الدالة، وقد رأينا هذا في مقال كيف يعمل Ethereum، والحل نمط السحب وتقسيم العمل.
ونمط السحب Pull يستحق وقفة لأنه يظهر في أكثر من صنف: إعادة الدخول، والاستدعاء غير المفحوص، وحجب الخدمة. والفكرة أن العقد لا يرسل المال لأحد، وإنما يسجل أن لكل مستفيد مبلغاً، ويأتي كل مستفيد ليسحب مبلغه بنفسه في معاملة منفصلة. وبهذا يحل ثلاث مشكلات معاً: إعادة الدخول تصبح أصعب لأن كل سحب معزول، والاستدعاء الفاشل لمستقبل واحد لا يؤثر على غيره لأن كل واحد يسحب وحده، وحجب الخدمة يختفي لأنه لا توجد حلقة ترسل للجميع فتتجمد بواحد معطل. والثمن أن المستفيد يدفع غاز سحبه بنفسه، ويجب أن يتذكر أن يسحب، ولكن هذا ثمن صغير مقابل أن العقد لا يعلق على مستقبل واحد. ولهذا نصح به تقرير King of the Ether نفسه، وهو من أقدم الدروس في هذا المجال وما زال صحيحاً.
لماذا الأمان هنا أغلى من أي مكان آخر؟
قبل أن ننتقل إلى الأدوات، يجب أن يفهم الفريق لماذا يستحق العقد الذكي كل هذا الحذر، بينما تطبيق الويب العادي يصلح خطؤه في ساعة. والسبب ثلاثة فروق اجتمعت كلها في مكان واحد. الأول أن الكود منشور للجميع كما شرحنا في مقال العقود الذكية، فالمهاجم يقرأ عقدك كاملاً قبل أن يهاجم، ولا توجد عندك ميزة الغموض التي يعتمد عليها بعض من يبني أنظمة مغلقة. والثاني أن الخطأ لا يصلح بعد النشر، فحتى لو اكتشفت الثغرة بعد دقيقة من استغلالها فلا زر توقف إلا إذا بنيته، ولا تراجع عن معاملة نفذت. والثالث أن المال موجود في العقد مباشرة، فالثغرة لا تعني تسريب بيانات تعالج لاحقاً، وإنما مالاً يخرج فوراً ولا يعود.
وهذه الفروق الثلاثة تقلب معادلة الحافز رأساً على عقب: في النظام العادي، مهاجم يجد ثغرة قد يبيعها أو يبتزك، وأنت تملك وقتاً وخيارات. أما هنا فمن يجد الثغرة يأخذ المال فوراً دون وسيط، فالحافز على الهجوم مباشر وضخم، وهذا يفسر لماذا تمر العقود التي تحمل قيمة كبيرة بمراجعة أمنية مستقلة Audit من شركات متخصصة، تقرأ الكود سطراً سطراً وتحاول كسره، وتدفع مقابلها مبالغ كبيرة قبل النشر، ولماذا تشغل برامج مكافأة اكتشاف الثغرات Bug Bounty التي تدفع لمن يبلغ عن ثغرة بدل أن يستغلها، فتحول حافز الباحث من الهجوم إلى الإبلاغ. والقاعدة التي خرج بها الفريق: تكلفة الأمان قبل النشر، مهما بدت كبيرة، أرخص دائماً من تكلفة ثغرة واحدة بعده، لأن الأولى تدفعها مرة وأنت تختار، والثانية تدفعها كاملة دفعة واحدة وأنت لا تملك شيئاً.
ومن هذه المعادلة يأتي قرار عملي يخص فريق منصتنا تحديداً: لا تبدأ بعقد يحمل أموال كل العملاء من اليوم الأول. ابدأ بسقف صغير للمبلغ الذي يحمله العقد، مثل مشاريع محدودة القيمة، فإذا وجدت ثغرة كان الضرر محدوداً، وزد السقف تدريجياً مع كل شهر يمر دون مشكلة ومع كل مراجعة تجتازها. فالعقد الذي يحمل القليل خطؤه يكلف القليل، والثقة في الكود تبنى بالوقت والتشغيل الحقيقي، لا بالاختبار وحده مهما كان دقيقاً.
التحليل الآلي: المدقق المدمج وSlither
الأدوات التي تقرأ الكود وتجد أنماط الثغرات دون تشغيله تسمى التحليل الساكن Static Analysis، وهي أرخص طبقة دفاع لأنها تعمل في ثوان بعد كل بناء. وقد رأينا المدقق المدمج في forge ينبهنا في المقالات السابقة. وهناك أدوات أعمق متخصصة في العقود، أشهرها Slither من شركة Trail of Bits، وهي مفتوحة المصدر برخصة AGPL-3.0، وتأتي جاهزة في صورة eth-security-toolbox. نشغلها على خزنتنا، مع تمرير مسار المكتبة:
docker run --rm -v "$PWD/lab:/lab" -w /lab/sec ghcr.io/trailofbits/eth-security-toolbox:nightly \
slither src/Vault.sol \
--solc-remaps "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/" \
--detect reentrancy-ethوالمخرج سوف يكون كما يلي:
Reentrancy in VulnerableVault.withdraw() (src/Vault.sol#13-19):
External calls:
- (ok,None) = msg.sender.call{value: amount}() (src/Vault.sol#16)
State variables written after the call(s):
- balance[msg.sender] = 0 (src/Vault.sol#18)
VulnerableVault.balance (src/Vault.sol#7) can be used in cross function reentrancies:
- VulnerableVault.balance (src/Vault.sol#7)
- VulnerableVault.deposit() (src/Vault.sol#9-11)
- VulnerableVault.withdraw() (src/Vault.sol#13-19)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#reentrancy-vulnerabilities-1
INFO:Slither:src/Vault.sol analyzed (3 contracts with 1 detectors), 1 result(s) foundأشارت Slither بدقة إلى الدالة withdraw في العقد المعطوب، وقالت بالضبط ما المشكلة: استدعاء خارجي في السطر 16، ثم متغير حالة يكتب بعده في السطر 18، وهذا هو تعريف إعادة الدخول. ولاحظ أنها لم تبلغ عن النسخة الآمنة SafeVault في نفس الملف، لأن ترتيبها صحيح، وكل ذلك دون أن نشغل العقد أصلاً. والأداة تجد أكثر من إعادة الدخول: عندما شغلناها بلا تحديد صنف وجدت تسع ملاحظات، منها المتغير الذي يجب أن يكون ثابتاً، والاستدعاء منخفض المستوى بلا فحص. ولكن هذه الأدوات تجد الأنماط المعروفة فقط، ولا تفهم منطق عملك: لن تعرف أن العميل يجب ألا يصرف قبل التسليم، لأن هذا قرار عملك أنت.
والفرق بين التحليل الساكن والاختبار الذي كتبناه فرق جوهري يجب أن يفهمه الفريق. التحليل الساكن يقرأ الكود ويبحث عن أنماط معروفة خطرة، فهو سريع جداً ويعمل على كل الكود دون أن يشغله، ولكنه لا يعرف هل النمط الذي وجده خطر فعلاً في سياقك أم لا، فيبلغ أحياناً عن أشياء سليمة، وهذا ما يسمى الإيجابيات الكاذبة False Positives، ويفوته أحياناً خطر لا يطابق نمطاً يعرفه. أما الاختبار فيشغل العقد فعلاً ويرى ماذا يحدث، فهو يثبت أن الثغرة تعمل أو لا تعمل بدل أن يخمن، ولكنه لا يجد إلا ما كتبت له أن يجربه، أو ما وصلت إليه العشوائية والثوابت. ولهذا الطبقتان تكملان بعضهما: الأداة الساكنة تمسح كل الكود بسرعة وتشير إلى الأماكن المشبوهة، والاختبار يثبت أيها خطر حقيقي ويحمي من عودته. ولا تغني واحدة عن الأخرى، كما لا يغني فحص طبي عام عن تحليل محدد.
وقاعدة عملية في التعامل مع مخرجات الأدوات: لا تتجاهل ملاحظة ولا تصلحها عمياء، وإنما صنف كل واحدة إلى ثلاث: خطر حقيقي تصلحه، أو إيجابية كاذبة تكتب بجانبها في الكود سبب تجاهلها بتعليق يراه المراجع، أو ملاحظة في كود اختبار أو مثال لا ينشر فلا تهم. ففي تجاربنا أشار المدقق إلى arbitrary-send-eth في عقد المهاجم، وهي ملاحظة صحيحة في مكانها، فعقد المهاجم فعلاً يرسل إلى عنوان حر، ولكنها لا تهمنا لأنه عقد هجوم للاختبار لا ينشر. وأخطر ما يحدث للفريق أن يعتاد تجاهل الملاحظات لكثرتها، فيتجاهل يوماً ملاحظة حقيقية، ولذلك كلما قل عدد الإيجابيات الكاذبة التي يراها الفريق، زاد انتباهه للحقيقية، وهذا سبب أن تضبط الأدوات لتتجاهل ما قررت تجاهله بدل أن تتركه يظهر في كل تشغيل.
الدفاع في طبقات
الخلاصة التي خرج بها فريق المنصة أن الأمان ليس أداة ولا سطراً، وإنما طبقات كل واحدة تمسك ما تفلته التي قبلها، لأن كل طبقة وحدها تخطئ:

- أنماط آمنة ومكتبات مراجعة: ترتيب تحقق ثم عدل ثم تواصل، ونمط السحب، ومكتبات مثل OpenZeppelin بدل الكتابة من الصفر، وهذه تمنع الخطأ قبل كتابته.
- اختبار يهاجم: اكتب الهجوم اختباراً كما فعلنا، ومعه الاختبار العشوائي والثوابت التي رأيناها في مقال أدوات التطوير.
- تحليل آلي: المدقق المدمج في كل بناء، وأداة مثل Slither في التكامل المستمر، وكلاهما رخيص وسريع.
- مراجعة بشرية: مراجعة مستقلة Audit من فريق متخصص، ومكافأة اكتشاف الثغرات Bug Bounty، لأن الأدوات لا تفهم منطق عملك.
- تشغيل محدود: مهلة زمنية على الصلاحيات، وإمكانية إيقاف في الطوارئ، وسقف مبالغ يبدأ صغيراً ويزيد مع الثقة، فالعقد الذي يحمل القليل خطؤه يكلف القليل.
أخطاء شائعة وكيف تلاحظها
- استدعاء خارجي قبل تحديث الحالة: تلاحظه عندما تجد سطر إرسال مال أو استدعاء عقد آخر يسبق تحديث متغير يعتمد عليه، والحل الترتيب الصحيح والحارس.
- دالة حساسة بلا فحص صلاحية: تلاحظها بسؤال واحد لكل دالة تحرك مالاً أو تغير إعداداً: من يحق له استدعاؤها، وأين الفحص.
- قرار مالي على سعر لحظي من مصدر واحد: تلاحظه عندما يقرأ العقد سعراً من مجمع واحد في نفس المعاملة، والحل متوسط زمني أو عدة مصادر.
- حساب في
uncheckedبلا إثبات: تلاحظه في مراجعة كل كتلةunchecked، فهي تلغي حماية المترجم. - قيمة عائدة من إرسال لا تفحص: تلاحظها في كل
sendأوcallلا يتبعهاrequire. - الاكتفاء بطبقة واحدة: تلاحظه عندما يقول الفريق إن الكود اجتاز المدقق الآلي فهو آمن، والحل أن تمر كل ثغرة بأكثر من طبقة.
قائمة تحقق أمنية قبل أي مال حقيقي
| الصنف | السؤال الذي تسأله | الإصلاح |
|---|---|---|
| إعادة الدخول | هل أحدث الحالة قبل كل استدعاء خارجي؟ | تحقق ثم عدل ثم تواصل، مع nonReentrant عند الحاجة |
| التحكم في الصلاحيات | من يحق له استدعاء كل دالة حساسة، وأين الفحص؟ | onlyOwner أو أدوار، ومحفظة متعددة التوقيع ومهلة للمفاتيح |
| التلاعب بالأوراكل | كم يكلف تحريك كل سعر يعتمد عليه العقد؟ | متوسط زمني TWAP، عدة مصادر، مغذي أسعار مصمم لذلك |
| تجاوز السعة | هل المترجم 0.8 أو أحدث، وهل كل unchecked مبرر؟ | مترجم حديث مثبت، ومراجعة كل كتلة unchecked |
| الاستدعاء غير المفحوص | هل أفحص القيمة العائدة من كل إرسال؟ | call مع require، ونمط السحب بدل الإرسال للجميع |
| العشوائية وحجب الخدمة | هل أعتمد على قيمة يتحكم فيها أحد، أو حلقة تكبر بلا حد؟ | مصدر عشوائية آمن، ونمط سحب يحد حجم كل معاملة |
| كل ما سبق | هل مرت على أكثر من طبقة قبل أي مال؟ | أنماط، اختبار يهاجم، تحليل آلي، مراجعة بشرية، سقف يبدأ صغيراً |
تدريب عملي
- أضف إلى
VulnerableVaultحارسnonReentrantدون أن تغير ترتيب السطرين، ثم أعد تشغيل هجوم إعادة الدخول وتأكد أنه فشل، وفكر لماذا نجح الحارس رغم بقاء الترتيب الخاطئ، ومتى لا يكفي وحده. - شغل Slither على كل عقود المجلد دون تحديد صنف، واقرأ الملاحظات التسع، وصنف كل واحدة: صحيحة يجب إصلاحها، أم في عقد المهاجم المقصود، أم ملاحظة لا تنطبق على سياقك، واكتب سبب تصنيفك.
حذف التجربة
docker rm -f lab-sec
docker image rm ghcr.io/trailofbits/eth-security-toolbox:nightly
rm -rf lab/sec/lib lab/sec/out lab/sec/cacheالخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- أمن العقود يدرس من جهة الدفاع: كل ثغرة نسخة من صنف معروف له إصلاح معروف، نكتبه ونهاجمه على شبكة محلية لنفهم كيف نمنعه.
- إعادة الدخول استدعاء خارجي قبل تحديث الحالة، وقد فرغ فيها مهاجم خزنتنا بسحب 11 ETH مقابل إيداع 1، وأوقفه ترتيب تحقق ثم عدل ثم تواصل، كما في حادثة The DAO.
- التحكم في الصلاحيات فحص غائب يسلم الدالة لأي أحد، وأصلحه
onlyOwner، وعلى مستوى المفاتيح أفرغ مهاجم جسر Ronin بتحكمه بخمسة من تسعة مفاتيح. - التلاعب بالأوراكل ثقة بسعر لحظي يمكن تحريكه، وقد رفع مهاجمنا السعر خمسة وعشرين ضعفاً في معاملة واحدة، كما رفع مهاجم Mango Markets سعر MNGO ثلاثة عشر ضعفاً.
- تجاوز السعة يصلحه مترجم 0.8، والاستدعاء غير المفحوص يصلحه فحص القيمة العائدة ونمط السحب، كما علمت حادثة King of the Ether.
- التحليل الآلي رخيص ويجد الأنماط المعروفة، وقد أشار Slither بدقة إلى السطر الخطأ دون تشغيل العقد، ولكنه لا يفهم منطق عملك.
- الأمان طبقات: أنماط آمنة، واختبار يهاجم، وتحليل آلي، ومراجعة بشرية، وتشغيل محدود بسقف يبدأ صغيراً، ولا تكفي أي طبقة وحدها.
وبهذا نكون قد أكملنا الجانب التقني من Ethereum والعقود الذكية. وفي المرحلة التالية ننتقل إلى التداول والمنصات والحفظ: كيف تعمل منصات التداول، وكيف تشترى العملات وتباع، ومن يحفظ المفاتيح، وما تقوله الجهات الرسمية في المملكة.
دليلما هي العقود الذكية Smart Contracts وما مخاطرها؟نكتب عقد ضمان لمنصة عمل حر ونجربه على شبكة محلية، لنرى أين يفي العقد الذكي بوعده وأين ينقل الثقة إلى مفتاح أو أوراكل.
دليلتطوير العقود الذكية بـ Solidity مع Foundry وHardhatنأخذ عقد الضمان إلى بيئة تطوير كاملة باختبارات وحدة وعشوائية وثوابت، ونرى كيف وجدت الثوابت خطأ لم تره ثمانية اختبارات.
دليلما هي الرموز Tokens؟ معيار ERC-20 ورموز NFTما الذي تملكه فعلاً عندما تقول المحفظة إن لديك رمزاً، ولماذا يحتاج العقد إذناً، وكيف تعلق الرموز داخل العقود إلى الأبد.
دليلكيف يعمل Ethereum؟ الحسابات والغاز وآلة EVMماذا يحدث داخل Ethereum عندما ترسل معاملة، ولماذا تدفع الرسوم حتى لو فشلت، ولماذا تختلف تكلفة نفس الدالة من مرة لأخرى.
دليلكيف يعمل البلوكتشين Blockchain ومتى لا تحتاجهما المشكلة التي يحلها البلوكتشين أصلاً وكيف يعمل من الداخل، ومتى تكون قاعدة البيانات العادية هي الحل الأصح.
دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتشغيل التجارب على Docker Engine 29.8.0 مع Foundry v1.8.6 وforge-std v1.17.0 وSolc 0.8.36 ومكتبة OpenZeppelin Contracts v5.7.0، وتحليل ساكن بأداة Slither 0.11.6 من صورة eth-security-toolbox، على شبكة Anvil محلية بعقود اختبار معطوبة بغرض الشرح.