کیف‌پول چندامضای Parity در ۲۰۱۷: یک تابع بی‌دفاع و پول قفل‌شده

در ۲۰۱۷ کیف‌پول چندامضای Parity دو بار آسیب دید: در تیرماه حدود ۱۵۰ هزار اتر دزدیده شد و در آبان‌ماه بیش از ۵۰۰ هزار اتر برای همیشه قفل شد. ریشه‌ی هر دو، تابعی بود که از آن محافظت نشده بود.

کیف‌پول چندامضای Parity در ۲۰۱۷: یک تابع بی‌دفاع و پول قفل‌شده

فرض کنید تیمی پول خود را در یک کیف‌پول چندامضا گذاشته است: برای هر برداشت بزرگ، چند نفر از اعضا باید جداگانه تأیید کنند. منطقی به نظر می‌رسد؛ اگر کلید یک نفر لو برود، پول هنوز امن است. حالا تصور کنید یک غریبه، بدون داشتن هیچ‌کدام از کلیدها، با یک تراکنش خودش را تنها مالک این کیف‌پول اعلام کند و با تراکنش دوم همه‌ی موجودی را ببرد.

این اتفاقی بود که در ژوئیه‌ی ۲۰۱۷ برای چند کیف‌پول چندامضای Parity افتاد. چهار ماه بعد، همان خانواده‌ی کیف‌پول‌ها دوباره آسیب دید، این بار با قفل شدن دائمی پول. این دو رویداد را جدا بررسی می‌کنیم، چون علت‌ها شبیه‌اند و نتیجه‌ها متفاوت.

کیف‌پول چندامضا و «کتابخانه‌ی مشترک» به زبان ساده

کیف‌پول چندامضا (multisig wallet) در اتریوم یک قرارداد هوشمند (smart contract) است، نه یک برنامه روی گوشی. این قرارداد فهرستی از مالکان دارد و قانونی مثل «۲ از ۳»: هر تراکنش باید به تعداد کافی تأیید برسد.

Parity برای کم کردن هزینه‌ی ساخت، هر کیف‌پول را قراردادی کوچک ساخته بود که فقط داده‌ها (مالکان، موجودی) را نگه می‌داشت. منطق اصلی در قراردادی جداگانه به نام کتابخانه (library) بود که همه‌ی کیف‌پول‌ها از آن استفاده می‌کردند.

ارتباط این دو با دستور delegatecall بود: کیف‌پول به کتابخانه می‌گوید «کدت را قرض بده تا روی داده‌های من اجرا کنم». کد مال کتابخانه است، اما تغییرها در حافظه‌ی خود کیف‌پول ثبت می‌شود.

این طراحی دو نقطه‌ی حساس دارد:

  • اگر کیف‌پول هر درخواستی را بی‌قید و شرط به کتابخانه بفرستد، هر تابع عمومی کتابخانه عملاً از بیرون قابل صدا زدن است.
  • اگر کتابخانه از بین برود، همه‌ی کیف‌پول‌هایی که به آن تکیه دارند بی‌کد می‌مانند.

حادثه‌ی اول از نقطه‌ی اول آمد و حادثه‌ی دوم از نقطه‌ی دوم.

حادثه‌ی اول: سرقت در ۱۹ ژوئیه‌ی ۲۰۱۷ (۲۸ تیر ۱۳۹۶)

در کتابخانه تابعی به نام initWallet وجود داشت. کارش راه‌اندازی اولیه بود: تعیین مالکان و تعداد تأیید لازم. چنین تابعی باید فقط یک بار، هنگام ساخت کیف‌پول، اجرا شود. به گزارش OpenZeppelin و تحلیل Haseeb Qureshi، دو اشکال کنار هم قرار گرفتند:

  1. تابع initWallet (و توابع داخلی آن) هیچ بررسی‌ای نداشت که «آیا این کیف‌پول قبلاً راه‌اندازی شده است؟» و به‌صورت داخلی (internal) هم علامت نخورده بود.
  2. تابع پیش‌فرض کیف‌پول (fallback function) هر فراخوانی ناشناخته را با delegatecall به کتابخانه می‌فرستاد.

پس مهاجم با یک تراکنش initWallet را از طریق خود کیف‌پول صدا زد و خودش را تنها مالک کرد، و با تراکنش دوم موجودی را برداشت. هیچ کلیدی دزدیده نشده بود؛ قرارداد خودش اجازه داد.

نمونه‌ای بسیار ساده‌شده (نه کد اصلی Parity):

// ناامن: هر کسی، هر زمانی می‌تواند مالکان را از نو تعیین کند
function initWallet(address[] memory owners, uint required) public {
    _setOwners(owners, required);
}

// امن‌تر: فقط یک بار و فقط هنگام راه‌اندازی
function initWallet(address[] memory owners, uint required) internal {
    require(ownerCount == 0, "already initialized");
    _setOwners(owners, required);
}

ابعاد: به گزارش OpenZeppelin، مهاجم ۱۵۳٬۰۳۷ اتر برداشت که در آن زمان حدود ۳۰ میلیون دلار ارزش داشت. به گزارش The Register، سه پروژه‌ی Edgeless Casino، Swarm City و æternity قربانی شدند. Parity به کاربران هشدار داد که پول کیف‌پول‌های چندامضای ساخته‌شده با نسخه‌ی ۱.۵ به بعد را فوراً جابه‌جا کنند.

نجات کلاه‌سفیدها: گروهی از برنامه‌نویسان داوطلب که خود را White Hat Group می‌نامیدند، از همان آسیب‌پذیری استفاده کردند تا پیش از مهاجم، موجودی کیف‌پول‌های در خطر را به حساب‌های امن منتقل کنند و بعداً به صاحبانشان برگردانند. The Register به نقل از CoinDesk مقدار نجات‌یافته را حدود ۳۷۷ هزار اتر گزارش کرد؛ Haseeb Qureshi ارزش دارایی‌های در خطر را با احتساب توکن‌ها حدود ۱۵۰ میلیون دلار تخمین زد.

پس از این حادثه، Parity نسخه‌ی اصلاح‌شده‌ی کتابخانه را منتشر کرد. در این نسخه، initWallet فقط وقتی اجرا می‌شد که کیف‌پول هنوز مالکی نداشت.

حادثه‌ی دوم: کتابخانه‌ای که خودش راه‌اندازی نشده بود (۶ نوامبر ۲۰۱۷، ۱۵ آبان ۱۳۹۶)

اصلاحیه‌ی ژوئیه یک چیز را در نظر نگرفته بود: خود کتابخانه هم یک قرارداد معمولی با حافظه‌ی خودش است. شرط «اگر مالکی نداری، راه‌اندازی کن» برای کیف‌پول‌ها درست کار می‌کرد، اما کتابخانه هرگز راه‌اندازی نشده بود و در حافظه‌ی خودش هیچ مالکی نداشت.

به گزارش پس‌مرگ‌نگاری (post-mortem) رسمی Parity و تحلیل OpenZeppelin، کاربری با نام devops199 در گیت‌هاب این اتفاق‌ها را رقم زد:

  1. initWallet را مستقیماً روی خود کتابخانه صدا زد، نه از طریق یک کیف‌پول. چون کتابخانه مالکی نداشت، شرط برقرار بود و او مالک کتابخانه شد.
  2. سپس تابع kill را صدا زد. این تابع با دستور selfdestruct کد قرارداد را از شبکه پاک می‌کرد.

از آن لحظه، صدها کیف‌پول که کدشان را از این کتابخانه قرض می‌گرفتند، به نشانی‌ای بی‌کد اشاره می‌کردند. پول در کیف‌پول‌ها ماند، اما هیچ تابعی برای برداشت آن نبود. کسی به پول دست پیدا نکرد؛ فقط قفل شد.

devops199 همان روز در گیت‌هاب Parity، در issue شماره‌ی ۶۹۹۵، نوشت: «I accidentally killed it.» (به اشتباه نابودش کردم). انگیزه‌ی واقعی او روشن نیست. به گزارش The Register، برخی تحلیل‌های بعدی ادعا کردند کار عمدی بوده است؛ این ادعا اثبات نشد.

ابعاد: به گزارش پس‌مرگ‌نگاری Parity (که Finance Magnates و The Register هم نقل کرده‌اند)، موجودی ۵۸۷ کیف‌پول، در مجموع ۵۱۳٬۷۷۴٫۱۶ اتر به‌علاوه‌ی توکن‌های دیگر، قفل شد. ارزش دلاری آن در گزارش‌های مختلف از حدود ۱۵۰ تا ۲۸۰ میلیون دلار ذکر شده است. به گزارش OpenZeppelin، بیش از ۳۰۰ هزار اتر از این مقدار به Web3 Foundation تعلق داشت.

Parity در همان گزارش پذیرفت که در اوت ۲۰۱۷ پیشنهادی برای رفع مشکل دریافت کرده اما آن را «بهبود جزئی» دیده و به تعویق انداخته بود، و اینکه بدون قابلیت نابودی، این قفل‌شدن رخ نمی‌داد.

آیا پول برگشت؟ پیشنهاد EIP-999 برای بازگرداندن کد کتابخانه (در نشانی 0x863D…07b4) با یک تغییر در سطح شبکه مطرح شد. وضعیت این پیشنهاد در سایت رسمی EIPها «Withdrawn» (پس‌گرفته‌شده) است و این راه اجرا نشد. اگر خواستید نشانی کتابخانه را در یک مرورگر بلاک (block explorer) ببینید و Etherscan از ایران باز نشد، می‌توانید از Blockscout استفاده کنید.

دو حادثه کنار هم

ژوئیه ۲۰۱۷ نوامبر ۲۰۱۷
هدف تک‌تک کیف‌پول‌ها کتابخانه‌ی مشترک
مسیر initWallet از طریق delegatecall کیف‌پول initWallet مستقیم روی کتابخانه، سپس kill
نتیجه سرقت حدود ۱۵۰ هزار اتر قفل حدود ۵۱۴ هزار اتر
پول کجا رفت به حساب مهاجم در کیف‌پول‌ها ماند، بدون راه برداشت

درس‌ها برای کاربران

  • چندامضا از چه محافظت می‌کند: از لو رفتن یا گم شدن کلید یک نفر، و از تصمیم یک‌نفره. این ارزش واقعی است.
  • چندامضا از چه محافظت نمی‌کند: از باگ در کد خود قرارداد کیف‌پول. در هر دو حادثه هیچ کلیدی لو نرفت؛ مشکل در کد بود.
  • ریسک وابستگی مشترک: وقتی کیف‌پول شما به قرارداد دیگری تکیه دارد، امنیت شما به امنیت آن قرارداد هم گره خورده است. یک خطا در یک نقطه، صدها کیف‌پول را هم‌زمان از کار انداخت.
  • پیش از گذاشتن مبلغ زیاد بپرسید: کد چند بار حسابرسی (audit) شده، چند سال بی‌حادثه کار کرده و آیا قابلیت نابودی یا ارتقای کنترل‌نشده دارد.
  • عبارت بازیابی (seed phrase) هیچ کیف‌پولی را برای «بررسی» یا «نجات» در هیچ سایت، اپ، چت یا هوش مصنوعی وارد نکنید. در حوادث بزرگ، کلاه‌برداران با پیام «پول شما را نجات می‌دهیم» سراغ قربانیان می‌روند.

درس‌ها برای برنامه‌نویسان

  • تابع راه‌اندازی (initializer) را قفل کنید. هر تابعی که نقش سازنده (constructor) را دارد باید فقط یک بار اجرا شود. مستندات OpenZeppelin برای قراردادهای قابل ارتقا (upgradeable) دقیقاً همین را می‌گوید: از initializer استفاده کنید و قرارداد پیاده‌سازی (implementation) را بی‌راه‌اندازی رها نکنید؛ در سازنده‌ی آن _disableInitializers() را صدا بزنید.
  • خود کتابخانه یا پیاده‌سازی را هم هدف حمله بدانید. اصلاحیه‌ی ژوئیه فقط مسیر «از طریق کیف‌پول» را بست و مسیر «مستقیم روی کتابخانه» را باز گذاشت.
  • delegatecall همه‌چیزخوار نسازید. به‌جای فرستادن هر فراخوانی ناشناخته به کتابخانه، فقط توابع مشخص و مجاز را عبور دهید.
  • کنترل دسترسی (access control) را روی هر تابع عمومی بررسی کنید. پیش‌فرض ذهنی باید این باشد: «هر کسی می‌تواند این را صدا بزند، مگر خلافش را ثابت کنم.»
  • قابلیت نابودی در کد مشترک خطرناک است. مستندات OpenZeppelin استفاده از selfdestruct و delegatecall را در قراردادهای پیاده‌سازی مجاز نمی‌داند. با EIP-6780 (فعال از به‌روزرسانی Cancun-Deneb، مارس ۲۰۲۴)، selfdestruct فقط در همان تراکنش ساخت قرارداد کد را پاک می‌کند؛ اما این رفتار شبکه جای طراحی امن را نمی‌گیرد.
  • گزارش‌های امنیتی را به تعویق نیندازید. هشداری که «بهبود جزئی» دیده شد، چند ماه بعد صدها کیف‌پول را قفل کرد.

خلاصه

  • ژوئیه‌ی ۲۰۱۷: تابع راه‌اندازی بی‌محافظ و delegatecall بی‌قید، سرقت حدود ۱۵۳ هزار اتر را ممکن کرد؛ کلاه‌سفیدها حدود ۳۷۷ هزار اتر را نجات دادند.
  • نوامبر ۲۰۱۷: کتابخانه‌ی مشترکِ راه‌اندازی‌نشده تصاحب و نابود شد و حدود ۵۱۳ هزار اتر در ۵۸۷ کیف‌پول قفل ماند.
  • چندامضا در برابر لو رفتن کلید محافظت می‌کند، نه در برابر باگ در کد قرارداد.
  • وابستگی به یک قرارداد مشترک یعنی یک نقطه‌ی شکست برای همه.
  • راه‌اندازی یک‌باره، کنترل دسترسی و پرهیز از قابلیت نابودی، سه درس اصلی برای برنامه‌نویس‌اند.

گام بعد

اگر می‌خواهید برنامه‌نویس قرارداد هوشمند شوید و چنین خطاهایی را پیش از رسیدن به شبکه‌ی اصلی بشناسید، «نقشه‌ی راه برنامه‌نویس قرارداد هوشمند» را دریافت کنید؛ این فایل رایگان ترتیب یادگیری از مبانی سالیدیتی تا امنیت را نشان می‌دهد. مباحثی مثل delegatecall، توابع راه‌اندازی و کنترل دسترسی در دوره‌ی A1 «مبانی امنیت قرارداد هوشمند» به‌صورت عملی و با تمرین بررسی می‌شوند؛ برای خبر شدن از انتشار، ثبت‌نام فهرست انتظار.

منابع

آخرین بازبینی دقت: ۱۴۰۵/۰۸/۰۶

این محتوا آموزشی است، نه توصیه‌ی سرمایه‌گذاری.

یک قدم جلوتر

فایل رایگان مرتبط را بگیر یا وارد فهرست انتظار دوره‌ها شو.

ابزارهای رایگان