رفتن به محتوای اصلی

امنیت در توکن‌های ERC-20؛ از استانداردسازی تا رفتارهای غیرمنتظره

امنیت در توکن‌های ERC-20؛ از استانداردسازی تا رفتارهای غیرمنتظره

‫امنیت ‬‫در‬ ‫توکن های‬ ‫‪ERC-20‬؛‬ ‫از‬ ‫استانداردسازی‬
‫تا‬‫رفتارهای‬ ‫غیرمنتظره‬

 

مقدمه


‫از‬‫ سال‬ ‫‪۲۰۱۵‬‬ ‫که‬ ‫اتریوم‬ ‫راه اندازی‬ ‫شد‪،‬‬ ‫یکی‬ ‫از‬ ‫مهم ترین‬ ‫دستاوردهای‬ ‫آن‬ ‫معرفی‬ ‫استاندارد‬ ‫‪ERC-20‬‬‫ بود‪.‬‬ ‫این‬ ‫استاندارد‬ ‫باعث‬ ‫شد‬ ‫هزاران‬ ‫توکن‬ ‫مختلف‪،‬‬ ‫از‬ ‫جمله

UNI، LINK، SHIB، USDT


‫و‬‫صدها‬ ‫توکن‬ ‫دیگر‪،‬‬ ‫به راحتی‬ ‫روی‬ ‫اتریوم‬ ‫ساخته‪،‬‬ ‫منتقل‬ ‫و‬ ‫در‬ ‫پروتکلهای‬ ‫مختلف‬ ‫استفاده‬
‫شوند‪.‬‬
‫اما‬‫پشت‬ ‫این‬ ‫ظاهر‬ ‫ساده‪،‬‬ ‫مسائل‬ ‫امنیتی‬ ‫زیادی‬ ‫وجود‬ ‫دارد‪.‬‬ ‫اگر‬ ‫توسعه دهنده ی‬ ‫یک‬ ‫توکن‬ ‫یا‬
‫پروتکلی‬‫که‬ ‫با‬ ‫توکنها‬ ‫تعامل‬ ‫دارد‬ ‫به‬ ‫این‬ ‫نکات‬ ‫توجه‬ ‫نکند‪،‬‬ ‫ممکن‬ ‫است‬ ‫سرمایه ی‬ ‫کاربران‬ ‫در‬
‫معرض‬‫خطر‬ ‫قرار‬ ‫بگیرد‪.‬‬
‫در‬‫این‬ ‫مقاله‪،‬‬ ‫به‬ ‫زبان‬ ‫ساده‬ ‫بررسی‬ ‫میکنیم‬ ‫که‬ ‫‪ERC-20‬‬ ‫چیست‪،‬‬ ‫چگونه‬ ‫کار‬ ‫میکند‬ ‫و‬ ‫مهمتر‬ ‫از‬
‫همه‪،‬‬‫در‬ ‫چه‬ ‫شرایطی‬ ‫یک‬ ‫توکن‬ ‫‪ERC-20‬‬ ‫میتواند‬ ‫رفتار‬ ‫ناامن‬ ‫یا‬ ‫غیرمنتظره‬ ‫داشته‬ ‫باشد‪.‬‬

 

‫چرا‬‫‪ERC-20‬‬ ‫به‬ ‫وجود‬ ‫آمد؟‬

 

‫قبل‬‫از‬ ‫معرفی‬ ‫استاندارد‬ ‫‪،ERC-20‬‬ ‫هر‬ ‫توسعهدهندهای‬ ‫که‬ ‫قصد‬ ‫ساخت‬ ‫توکن‬ ‫داشت‪،‬‬ ‫توابع‬ ‫و‬
‫منطق‬‫قرارداد‬ ‫را‬ ‫بر‬ ‫اساس‬ ‫سلیقهی‬ ‫خودش‬ ‫نامگذاری‬ ‫و‬ ‫پیادهسازی‬ ‫میکرد‪.‬‬ ‫این‬ ‫موضوع‬ ‫باعث‬
‫مشکالت‬‫جدی‬ ‫در‬ ‫تعامل‬ ‫میان‬ ‫توکنها‪،‬‬ ‫والتها‪،‬‬ ‫صرافیها‬ ‫و‬ ‫قراردادهای‬ ‫هوشمند‬ ‫میشد‪.‬‬
‫برای‬‫مثال‪،‬‬ ‫اگر‬ ‫یک‬ ‫پروتکل‬ ‫میخواست‬ ‫با‬ ‫‪۱۰‬‬ ‫توکن‬ ‫مختلف‬ ‫تعامل‬ ‫کند‪،‬‬ ‫باید‬ ‫برای‬ ‫هر‬ ‫توکن‬ ‫منطق‬
‫جداگانهای‬‫مینوشت؛‬ ‫چون‬ ‫هیچ‬ ‫استاندارد‬ ‫مشترکی‬ ‫وجود‬ ‫نداشت‪.‬‬ ‫پروتکل‬ ‫مجبور‬ ‫بود‬ ‫کد‬ ‫هر‬
‫توکن‬‫را‬ ‫بهصورت‬ ‫جداگانه‬ ‫بررسی‬ ‫کند‬ ‫تا‬ ‫بفهمد‬ ‫توابع‬ ‫آن‬ ‫چگونه‬ ‫کار‬ ‫میکنند‪.‬‬
‫فرض‬‫کنید‬ ‫یک‬ ‫قرارداد‬ ‫بخواهد‬ ‫توکن‬ ‫را‬ ‫از‬ ‫کاربر‬ ‫دریافت‬ ‫و‬ ‫داخل‬ ‫خودش‬ ‫نگهداری‬ ‫کند‪.‬‬ ‫در‬ ‫نبود‬
‫استاندارد‪،‬‬‫باید‬ ‫برای‬ ‫هر‬ ‫توکن‬ ‫بهصورت‬ ‫جداگانه‬ ‫تشخیص‬ ‫میداد‬ ‫که‬ ‫تابع‬ ‫انتقال‬ ‫چیست‪،‬‬ ‫چه‬
‫پارامترهایی‬‫دارد‬ ‫و‬ ‫در‬ ‫صورت‬ ‫موفقیت‬ ‫یا‬ ‫شکست‬ ‫چه‬ ‫رفتاری‬ ‫نشان‬ ‫میدهد‪.‬‬ ‫این‬ ‫کار‬ ‫پیچیده‪،‬‬
‫زمانبر‬‫و‬ ‫بسیار‬ ‫مستعد‬ ‫خطا‬ ‫بود‪.‬‬

‫با‬‫معرفی‬ ‫‪،ERC-20‬‬ ‫این‬ ‫مشکل‬ ‫تا‬ ‫حد‬ ‫زیادی‬ ‫حل‬ ‫شد‪.‬‬ ‫امروزه‬ ‫بیشتر‬ ‫توکنهای‬ ‫اتریوم‬ ‫از‬ ‫نظر‬ ‫نام‬
‫توابع‪،‬‬‫پارامترها‬ ‫و‬ ‫رفتار‬ ‫کلی‪،‬‬ ‫ساختار‬ ‫مشابهی‬ ‫دارند‪.‬‬ ‫این‬ ‫موضوع‬ ‫باعث‬ ‫شد‬ ‫والتها‪،‬‬ ‫صرافیها‬ ‫و‬
‫اپلیکیشنهای‬‫غیرمتمرکز‬ ‫بتوانند‬ ‫بدون‬ ‫نیاز‬ ‫به‬ ‫پیادهسازی‬ ‫جداگانه‬ ‫برای‬ ‫هر‬ ‫توکن‪،‬‬ ‫با‬ ‫توکنهای‬
‫‪ERC-20‬‬‫تعامل‬ ‫کنند‪.‬‬

 

‫توابع ‬‫اصلی‬ ‫در‬ ‫استاندارد‬ ‫‪ERC-20‬‬

 

هر‬‫توکن‬ ‫‪ERC-20‬‬ ‫باید‬ ‫حداقل‬ ‫مجموع های‬ ‫از‬ ‫توابع‬ ‫مشخص‬ ‫را‬ ‫پیادهسازی‬ ‫کند‪.‬‬ ‫مهمترین‬ ‫این‬
‫توابع ‬‫عبارت اند‬ ‫از‪:‬‬  

totalSupply()

‫نمایش‬‫ تعداد‬ ‫کل‬ ‫توکنهای‬ ‫موجود‪.‬‬

balanceOf(address)

‫نمایش ‬‫موجودی‬ ‫یک‬ ‫آدرس‬ ‫مشخص‪.‬‬

transfer(address, amount)

‫انتقال‬‫توکن‬ ‫از‬ ‫فرستنده‬ ‫به‬ ‫یک‬ ‫آدرس‬ ‫دیگر‪.‬‬

approve(address, amount)

‫اجازه‬‫دادن‬ ‫به‬ ‫یک‬ ‫آدرس‬ ‫دیگر‬ ‫برای‬ ‫خرج‬ ‫کردن‬ ‫مقدار‬ ‫مشخصی‬ ‫از‬ ‫توکن‪.‬‬

transferFrom(address, address, amount)

‫انتقال‬‫توکن‬ ‫از‬ ‫طرف‬ ‫یک‬ ‫شخص‬ ‫ثالث‪،‬‬ ‫بر‬ ‫اساس‬ ‫‪allowance‬‬ ‫قبلی‪.‬‬

allowance(address, address)

‫بررسی‬‫ مقدار‬ ‫توکنی‬ ‫که‬ ‫یک‬ ‫آدرس‬ ‫اجازه‬ ‫دارد‬ ‫از‬ ‫طرف‬ ‫آدرس‬ ‫دیگر‬ ‫خرج‬ ‫کند‪.‬‬


‫این ‬‫توابع‬ ‫باعث‬ ‫میشوند‬ ‫ابزارهای‬ ‫مختلف‬ ‫بتوانند‬ ‫با‬ ‫یک‬ ‫مدل‬ ‫مشترک‬ ‫با‬ ‫توکنها‬ ‫ارتباط‬ ‫برقرار‬
‫کنند‪.‬‬

 

‫توکن های‬‫‪ERC-20‬‬ ‫و‬ ‫رفتارهای‬ ‫غیرمنتظره‬

 

‫هرچند‬‫‪ERC-20‬‬ ‫سازگاری‬ ‫زیادی‬ ‫ایجاد‬ ‫کرده‪،‬‬ ‫اما‬ ‫این‬ ‫استاندارد‬ ‫از‬ ‫نظر‬ ‫امنیتی‬ ‫بسیار‬ ‫باز‬ ‫و‬
‫انعطافپذیر‬‫است‪.‬‬ ‫در‬ ‫عمل‪،‬‬ ‫‪ERC-20‬‬ ‫بیشتر‬ ‫شبیه‬ ‫یک‬ ‫‪interface‬‬ ‫ساده‬ ‫است‬ ‫تا‬ ‫یک‬ ‫استاندارد‬
‫سختگیرانه‬‫و‬ ‫دقیق‪.‬‬

 

‫به‬‫ همین‬ ‫دلیل‪،‬‬ ‫بسیاری‬ ‫از‬ ‫توسعه دهندگان‬ ‫توکنها‬ ‫را‬ ‫با‬ ‫رفتارهای‬ ‫خاص‬ ‫و‬ ‫گاهی‬ ‫غیراستاندارد‬
‫پیاده سازی‬‫ می کنند‪.‬‬ ‫در‬ ‫جامعه ی‬ ‫امنیت‬ ‫قراردادهای‬ ‫هوشمند‪،‬‬ ‫به‬ ‫این‬ ‫دسته‬ ‫از‬ ‫توکنها‬ ‫معموال‬ ‫ً‬
‫‪Tokens‬‬‫‪ERC20‬‬ ‫‪Weird‬‬ ‫گفته‬ ‫می شود‪.‬‬
‫این‬‫ توکنها‬ ‫ممکن‬ ‫است‬ ‫در‬ ‫ظاهر‬ ‫‪ERC-20‬‬ ‫باشند‪،‬‬ ‫اما‬ ‫در‬ ‫عمل‬ ‫رفتارهایی‬ ‫داشته‬ ‫باشند‬ ‫که‬
‫پروتکل ها‬‫ انتظار‬ ‫آن‬ ‫را‬ ‫ندارند‪.‬‬ ‫همین‬ ‫موضوع‬ ‫در‬ ‫گذشته‬ ‫باعث‬ ‫سوءاستفاده‬ ‫و‬ ‫هک‬ ‫چندین‬ ‫قرارداد‬
‫هوشمند‬‫شده‬ ‫است‪.‬‬
‫بنابراین‪،‬‬‫وقتی‬ ‫یک‬ ‫قرارداد‬ ‫هوشمند‬ ‫با‬ ‫توکن های‬ ‫خارجی‬ ‫تعامل‬ ‫میکند‪،‬‬ ‫نباید‬ ‫فرض‬ ‫کند‬ ‫که‬ ‫همه ی‬
‫توکنها‬‫ دقیقا‬ ‫ً‬ ‫طبق‬ ‫انتظار‬ ‫عمل‬ ‫میکنند‪.‬‬
‫برای ‬‫کاهش‬ ‫ریسک‪،‬‬ ‫توسعه دهندگان‬ ‫حرفه ای‬ ‫معمولا ‫از‬ ‫روشهای‬ ‫زیر‬ ‫استفاده‬ ‫میکنند‪:‬‬
‫استفاده‬‫از‬ ‫‪allowlist‬‬ ‫برای‬ ‫توکنهای‬ ‫معتبر‬ ‫و‬ ‫بررسی شده‪.‬‬ ‫
‫تعامل‬‫ با‬ ‫توکنها‬ ‫از‬ ‫طریق‬ ‫‪wrapper‬های‬ ‫جداگانه‪.‬‬ 
‫بررسی‬‫‪balance‬‬ ‫قبل‬ ‫و‬ ‫بعد‬ ‫از‬ ‫انتقال‪.‬‬ ‫
‫استفاده‬‫ از‬ ‫کتابخانه هایی‬ ‫مانند‬ ‫‪.SafeERC20‬‬ ‫
‫پرهیز ‬‫از‬ ‫فرض های‬ ‫خطرناک‬ ‫درباره ی‬ ‫رفتار‬ ‫توکن‪.‬‬ ‫
‫در‬‫ادامه‪،‬‬ ‫چند‬ ‫مورد‬ ‫از‬ ‫رایج ترین‬ ‫رفتارهای‬ ‫غیراستاندارد‬ ‫و‬ ‫آسیبپذیریهای‬ ‫مرتبط‬ ‫با‬ ‫توکنهای‬
‫‪ERC-20‬‬‫را‬ ‫بررسی‬ ‫میکنیم‪.‬‬

1- Missing Return Values

‫یکی‬‫ از‬ ‫نکات‬ ‫مهم‬ ‫هنگام‬ ‫آدیت‬ ‫تعامل‬ ‫با‬ ‫توکنهای‬ ‫‪،ERC-20‬‬ ‫بررسی‬ ‫مقدار‬ ‫برگشتی‬ ‫توابعی‬ ‫مثل‬
‫‪transfer‬‬‫‪،‬‬ ‫‪transferFrom‬‬ ‫و‬ ‫‪approve‬‬ ‫است‪.‬‬
‫طبق ‬‫استاندارد‬ ‫‪،ERC-20‬‬ ‫این‬ ‫توابع‬ ‫باید‬ ‫مقدار‬ ‫‪bool‬‬ ‫برگردانند؛‬ ‫یعنی‬ ‫‪true‬‬ ‫برای‬ ‫موفقیت‬ ‫و‬
‫‪false‬‬‫برای‬ ‫شکست‪.‬‬ ‫اما‬ ‫مشکل‬ ‫اینجاست‬ ‫که‬ ‫بسیاری‬ ‫از‬ ‫توکنهای‬ ‫واقعی‬ ‫دقیقا‬ ‫ً ‫مطابق‬
‫استاندارد‬‫رفتار‬ ‫نمیکنند‪.‬‬
‫برخی‬‫ توکن ها‬ ‫اصالا‫ً‬ ‫مقدار‬ ‫‪bool‬‬ ‫برنمیگردانند‪.‬‬ ‫بعضی‬ ‫دیگر‬ ‫نیز‬ ‫ممکن‬ ‫است‬ ‫به‬ ‫جای‬
‫‪  revert‬‬‫کردن‪،‬‬ ‫فقط‬ ‫مقدار‬ ‫‪false‬‬ ‫برگردانند‪.‬‬ ‫اگر‬ ‫قرارداد‬ ‫مقدار‬ ‫برگشتی‬ ‫را‬ ‫بررسی‬ ‫نکند‪،‬‬
‫ممکن‬‫ است‬ ‫تصور‬ ‫کند‬ ‫انتقال‬ ‫موفق‬ ‫بوده‪،‬‬ ‫در‬ ‫حالی‬ ‫که‬ ‫در‬ ‫واقع‬ ‫هیچ‬ ‫توکنی‬ ‫منتقل‬ ‫نشده‬ ‫است‪.‬‬
‫نمونه ی‬‫ ساده‪: ‬‬

token.transfer(user, amount) 
balances[user] -= amount

‫در‬‫این‬ ‫مثال‪،‬‬ ‫اگر‬ ‫‪transfer‬‬ ‫مقدار‬ ‫‪false‬‬ ‫برگرداند‬ ‫اما‬ ‫تراکنش‬ ‫را‬ ‫‪revert‬‬ ‫نکند‪،‬‬ ‫منطق‬
‫قرارداد ‬‫ادامه‬ ‫پیدا‬ ‫میکند‬ ‫و‬ ‫‪state‬‬ ‫داخلی‬ ‫قرارداد‬ ‫ممکن‬ ‫است‬ ‫خراب‬ ‫شود‪.‬‬
‫راه‬‫ بهتر‬ ‫این‬ ‫است‬ ‫که‬ ‫مقدار‬ ‫برگشتی‬ ‫بررسی‬ ‫شود‪:‬‬

 

require(token.transfer(user, amount), "TRANSFER_FAILED");

 

‫اما‬‫این‬ ‫روش‬ ‫هم‬ ‫همیشه‬ ‫کافی‬ ‫نیست؛‬ ‫چون‬ ‫برخی‬ ‫توکنها‪،‬‬ ‫مانند‬ ‫بعضی‬ ‫پیاده سازیهای‬ ‫‪،USDT‬‬
‫اصالا‬‫ً‬ ‫مقدار‬ ‫برگشتی‬ ‫ندارند‪.‬‬ ‫برای‬ ‫همین‬ ‫معموالا ‫ً‬ ‫بهتر‬ ‫است‬ ‫از‬ ‫‪SafeERC20‬‬ ‫استفاده‬ ‫شود‪:‬‬

 

using SafeERC20 for IERC20;

token.safeTransfer(user, amount);

 

‫کتابخانه ی‬‫‪SafeERC20‬‬ ‫بسیاری‬ ‫از‬ ‫ناسازگاریهای‬ ‫رایج‬ ‫در‬ ‫‪value‬‬ ‫‪return‬‬ ‫را‬ ‫مدیریت‬ ‫میکند‪.‬‬
‫با‬‫این‬ ‫حال‪،‬‬ ‫نباید‬ ‫تصور‬ ‫کرد‬ ‫که‬ ‫این‬ ‫کتابخانه‬ ‫تمام‬ ‫مشکلات ‫امنیتی‬ ‫مرتبط‬ ‫با‬ ‫توکنها‬ ‫را‬ ‫حل‬
‫میکند‪.‬‬‫برخی‬ ‫توکنها‬ ‫رفتارهای‬ ‫پیچیده تری‬ ‫دارند؛‬ ‫برای‬ ‫مثال‪:‬‬
‫‪fee-on-transfer‬‬‫هستند‪.‬‬ 
‫دارای‬‫‪blocklist‬‬ ‫هستند‪.‬‬ 
‫قابلیت‬‫‪pause‬‬ ‫دارند‪.‬‬ 
‫مقدار‬‫واقعی‬ ‫دریافتی‬ ‫با‬ ‫مقدار‬ ‫ارسالی‬ ‫متفاوت‬ ‫است‪.‬‬ 
‫بنابراین‬‫در‬ ‫آدیت‪،‬‬ ‫فقط‬ ‫نباید‬ ‫بررسی‬ ‫شود‬ ‫که‬ ‫آیا‬ ‫‪transfer‬‬ ‫صدا‬ ‫زده‬ ‫شده‬ ‫یا‬ ‫نه‪.‬‬ ‫مهمتر‬ ‫این‬
‫است‬‫ که‬ ‫ببینیم‬ ‫قرارداد‬ ‫بعد‬ ‫از‬ ‫انتقال‬ ‫چه‬ ‫فرضی‬ ‫درباره ی‬ ‫موفقیت‪،‬‬ ‫مقدار‬ ‫دریافتی‬ ‫و‬ ‫رفتار‬ ‫توکن‬
‫دارد‪.‬‬

 

2- ‫‪Reentrant Calls 

 

‫یکی‬‫از‬ ‫رفتارهای‬ ‫خطرناک‬ ‫در‬ ‫برخی‬ ‫توکنها‬ ‫این‬ ‫است‬ ‫که‬ ‫هنگام‬ ‫اجرای‬ ‫‪transfer‬‬ ‫یا‬
‫‪transferFrom‬‬‫میتوانند‬ ‫دوباره‬ ‫به‬ ‫قرارداد‬ ‫‪caller‬‬ ‫برگردند‬ ‫و‬ ‫تابعی‬ ‫از‬ ‫آن‬ ‫را‬ ‫صدا‬ ‫بزنند‪.‬‬

 

‫توکن های‬‫ معمولی‬ ‫‪ERC-20‬‬ ‫معموالا‬ ‫ً‬ ‫چنین‬ ‫رفتاری‬ ‫ندارند‪،‬‬ ‫اما‬ ‫توکنهایی‬ ‫مانند‬ ‫‪ERC-777‬‬ ‫به‬ ‫دلیل‬
‫داشتن‬‫‪hook‬‬ ‫میتوانند‬ ‫هنگام‬ ‫انتقال‪،‬‬ ‫کد‬ ‫خارجی‬ ‫اجرا‬ ‫کنند‪.‬‬ ‫مشکل‬ ‫زمانی‬ ‫ایجاد‬ ‫میشود‬ ‫که‬
‫قرارداد‬‫قبل‬ ‫از‬ ‫کامل‬ ‫کردن‬ ‫‪state‬‬ ‫داخلی‬ ‫خود‪،‬‬ ‫با‬ ‫یک‬ ‫توکن‬ ‫خارجی‬ ‫تعامل‬ ‫کند‪.‬‬
‫نمونه ی‬‫ ساده‪:‬‬

token.transferFrom(user, address(this), amount); 
balances[user] += amount;

 

اگر توکن مخرب یا hookدار باشد، ممکن است داخل همین transferFrom دوباره وارد قرارداد شود و از state ناقص سوءاستفاده کند.

نمونه‌ای از یک توکن reentrant:

 

function transferFrom(address src, address dst, uint wad)
    public
    override
    returns (bool res)
{

    res = super.transferFrom(src, dst, wad);


    Target memory target = targets[src];


    if (target.addr != address(0)) {
        (bool status,) = target.addr.call{gas: gasleft()}(target.data);
        require(status, "call failed");

    }

}

 

در این مثال، بعد از اجرای transferFrom، توکن یک call دلخواه به آدرسی که کاربر مشخص کرده ارسال می‌کند. اگر قرارداد اصلی تصور کند انتقال توکن فقط یک عملیات ساده و بدون اجرای کد خارجی است، ممکن است آسیب‌پذیر شود.

این نوع مشکل در دنیای واقعی نیز باعث exploit شده است؛ از جمله در حملات مرتبط با imBTC در Uniswap و exploit پروتکل lendf.me.

در آدیت، نباید فرض کرد تعامل با توکن همیشه passive و بی‌خطر است. هر transfer، transferFrom یا حتی safeTransfer می‌تواند یک external call خطرناک باشد.

نمونه‌ی الگوی خطرناک:

 

token.transferFrom(user, address(this), amount); 
balances[user] += amount;

 

روش بهتر این است که منطق قرارداد تا حد امکان طبق الگوی Checks-Effects-Interactions نوشته شود یا از محافظ‌هایی مانند nonReentrant استفاده شود:

 

function deposit(uint256 amount) external nonReentrant {
    uint256 balanceBefore = token.balanceOf(address(this));

    token.safeTransferFrom(msg.sender, address(this), amount);

    uint256 balanceAfter = token.balanceOf(address(this));
    uint256 received = balanceAfter - balanceBefore;

    balances[msg.sender] += received;
}

 

البته همین روش نیز همیشه کافی نیست؛ چون اگر توکن fee-on-transfer باشد، مقدار واقعی دریافتی ممکن است کمتر از amount باشد. در چنین مواردی، بهتر است balance قبل و بعد از انتقال بررسی شود.

خلاصه اینکه در آدیت، انتقال توکن نباید مانند یک تغییر balance ساده دیده شود. تعامل با توکن خارجی خودش یک external call است و می‌تواند مسیر reentrancy باز کند.

 

3-  ‫‪Fee-on-Transfer Tokens

برخی ‬‫توکن ها‬ ‫هنگام‬ ‫انتقال‪،‬‬ ‫بخشی‬ ‫از‬ ‫مقدار‬ ‫انتقال‬ ‫را‬ ‫بهعنوان‬ ‫‪fee‬‬ ‫کم‬ ‫میکنند‪.‬‬ ‫یعنی‬ ‫اگر‬ ‫قرارداد‬
‫‪۱۰۰‬‬‫توکن‬ ‫منتقل‬ ‫کند‪،‬‬ ‫گیرنده‬ ‫ممکن‬ ‫است‬ ‫واقعا‬ ‫ً‬ ‫‪۱۰۰‬‬ ‫توکن‬ ‫دریافت‬ ‫نکند؛‬ ‫مثال‬ ‫ً‬ ‫فقط‬ ‫‪۹۹‬‬ ‫توکن‬ ‫به‬
‫مقصد‬‫برسد‪.‬‬
‫مشکل‬‫ اصلی‬ ‫زمانی‬ ‫ایجاد‬ ‫میشود‬ ‫که‬ ‫قرارداد‬ ‫فقط‬ ‫به‬ ‫مقدار‬ ‫ورودی‬ ‫اعتماد‬ ‫کند‬ ‫و‬ ‫بررسی‬ ‫نکند‬
‫واقعا‬‫ً‬ ‫چند‬ ‫توکن‬ ‫دریافت‬ ‫کرده‬ ‫است‪.‬‬
‫نمونه ی ‬‫آسیب پذیر‪:‬‬

 

token.transferFrom(user, address(this), amount); 
balances[user] += amount;

 

‫در‬‫این‬ ‫مثال‪،‬‬ ‫قرارداد‬ ‫فرض‬ ‫میکند‬ ‫دقیقا‬ ‫ً‬ ‫به‬ ‫اندازه ی‬ ‫‪amount‬‬ ‫توکن‬ ‫دریافت‬ ‫کرده‬ ‫است‪.‬‬ ‫اما‬
‫اگر‬‫توکن‬ ‫‪fee-on-transfer‬‬ ‫باشد‪،‬‬ ‫مقدار‬ ‫واقعی‬ ‫دریافتی‬ ‫کمتر‬ ‫خواهد‬ ‫بود‬ ‫و‬ ‫‪accounting‬‬ ‫داخلی‬
‫قرارداد‬‫خراب‬ ‫میشود‪.‬‬

 

‫نمونه ای ‬‫از‬ ‫منطق‬ ‫یک‬ ‫توکن‬ ‫‪:fee-on-transfer‬‬

balanceOf[src] = balanceOf[src].sub(wad); 

balanceOf[dst] = balanceOf[dst].add(wad.sub(fee)); 

balanceOf[address(0)] = balanceOf[address(0)].add(fee);

 

در این مدل، از فرستنده کل wad کم می‌شود، اما گیرنده فقط wad - fee دریافت می‌کند. مقدار fee نیز ممکن است به آدرس صفر منتقل یا عملاً burn شود.

این رفتار در exploitهای واقعی نیز دیده شده است. یکی از نمونه‌های معروف، حمله به چند pool در Balancer با توکن STA بود. به دلیل fee-on-transfer بودن STA، محاسبات pool با balance واقعی هماهنگ نبود و همین موضوع باعث drain شدن بخشی از نقدینگی شد.

برای همین، هنگام آدیت هر جایی که قرارداد با توکن خارجی کار می‌کند، نباید صرفاً به amount ورودی اعتماد کرد؛ به‌خصوص در عملیات‌هایی مانند:

  • deposit
  • swap
  • repay
  • lending
  • reward accounting
  • liquidity pool accounting

روش امن‌تر این است که مقدار واقعی دریافتی با balance قبل و بعد محاسبه شود:

 

uint256 balanceBefore = token.balanceOf(address(this));

token.transferFrom(user, address(this), amount); 

uint256 balanceAfter = token.balanceOf(address(this)); 

uint256 received = balanceAfter - balanceBefore;

balances[user] += received;

 

نکته‌ی مهم این است که safeTransferFrom این مشکل را حل نمی‌کند.

SafeERC20 فقط کمک می‌کند call و return value بهتر مدیریت شوند، اما تضمین نمی‌کند گیرنده دقیقاً به اندازه‌ی amount توکن دریافت کرده باشد.

‫قانون‬‫ ساده‬ ‫این‬ ‫است‪:‬‬ ‫هر‬ ‫وقت‬ ‫قرارداد‬ ‫بعد‬ ‫از‬ ‫دریافت‬ ‫توکن‪،‬‬ ‫‪state‬‬ ‫خودش‬ ‫را‬ ‫بر‬ ‫اساس‬
‫‪  amount‬‬‫آپدیت‬ ‫میکند‪،‬‬ ‫باید‬ ‫بررسی‬ ‫شود‬ ‫آیا‬ ‫مقدار‬ ‫واقعی‬ ‫دریافتی‬ ‫همان‬ ‫‪amount‬‬ ‫بوده‬ ‫یا‬ نه‪.‬‬

 

4-  ‫‪Upgradable Tokens

 

 

برخی توکن‌ها پشت proxy قرار دارند و قابل upgrade هستند؛ مانند USDC و USDT. یعنی owner یا admin توکن می‌تواند در آینده logic قرارداد را تغییر دهد.

مشکل اینجاست که یک پروتکل ممکن است بر اساس رفتار فعلی توکن طراحی شده باشد، اما بعد از upgrade، رفتار توکن تغییر کند. برای مثال ممکن است:

  • fee اضافه شود.
  • blacklist فعال‌تر شود.
  • transfer محدود شود.
  • return value تغییر کند.
  • منطق transfer یا transferFrom عوض شود.

بنابراین، وقتی یک قرارداد با توکن upgradable کار می‌کند، نباید فرض کند رفتار توکن برای همیشه ثابت می‌ماند.

نمونه‌ای ساده از منطق upgrade در proxy:

 

function upgrade(address impl) public auth { 

bytes32 slot = IMPLEMENTATION_KEY; 

assembly { 

       sstore(slot, impl) 

    }

 }

 

در این مدل، admin می‌تواند implementation جدیدی برای توکن تنظیم کند. از بیرون، آدرس توکن همان آدرس قبلی باقی می‌ماند، اما logic داخلی آن تغییر می‌کند.

این موضوع برای پروتکل‌هایی که به رفتار خاص یک توکن وابسته هستند بسیار مهم است. اگر پروتکل فرض کرده باشد توکن fee ندارد، pause نمی‌شود یا همیشه transfer معمولی انجام می‌دهد، یک upgrade می‌تواند تمام این فرضیات را از بین ببرد.

 

5- Flash Mintable Tokens

 

 

برخی توکن‌ها قابلیت flash mint دارند. یعنی در طول یک تراکنش می‌توان مقدار زیادی از آن توکن را mint کرد، به شرطی که تا پایان همان تراکنش دوباره بازگردانده یا burn شود.

این رفتار شبیه flash loan است؛ با این تفاوت که در flash loan باید از قبل liquidity وجود داشته باشد، اما در flash mint لازم نیست توکن‌ها از قبل وجود داشته باشند. توکن در همان لحظه ساخته می‌شود و در پایان تراکنش باید تسویه شود.

مشکل زمانی ایجاد می‌شود که یک پروتکل به totalSupply، balance لحظه‌ای یا مقدار توکن موجود در بازار اعتماد کند.

نمونه:

 

uint256 supply = token.totalSupply();

 

اگر پروتکل بر اساس این مقدار تصمیم‌گیری کند، یک attacker ممکن است داخل همان تراکنش مقدار بزرگی توکن flash mint کند و محاسبات پروتکل را تغییر دهد.

همچنین اگر مواردی مانند price، collateral ratio، voting power، share یا reward calculation بر اساس balance لحظه‌ای یا totalSupply انجام شود، flash mint می‌تواند باعث دستکاری نتایج در همان تراکنش شود.

نمونه‌ی ساده:

 

uint256 votingPower = token.balanceOf(msg.sender);

require(votingPower > threshold, "not enough power");

 

اگر توکن flash mintable باشد، attacker ممکن است فقط برای همان تراکنش balance زیادی بسازد، از شرط عبور کند و در پایان تراکنش توکن‌ها را برگرداند یا burn کند.

در نتیجه، هنگام آدیت باید بررسی شود که آیا پروتکل به snapshot امن، TWAP، delay، checkpoint یا مکانیزم مقاوم در برابر تغییرات لحظه‌ای نیاز دارد یا نه.

 

6- Tokens with Blocklists

 

برخی‬‫ توکن ها‬ ‫مانند‬ ‫‪USDC‬‬ ‫و‬ ‫‪USDT‬‬ ‫قابلیت‬ ‫‪blocklist‬‬ ‫دارند‪.‬‬ ‫یعنی‬ ‫‪admin‬‬ ‫توکن‬ ‫میتواند‬ ‫یک‬
‫آدرس ‬‫را‬ بلاک ‫کند‬ ‫و‬ ‫بعد‬ ‫از‬ ‫آن‪،‬‬ 
Transfer  ‫از‬ ‫آن‬ ‫آدرس‬ ‫یا‬ ‫به‬ ‫آن‬ ‫آدرس‬ ‫انجام‬ ‫نشود‪.‬‬

این موضوع برای پروتکل‌ها بسیار مهم است، چون ممکن است دارایی داخل یک قرارداد گیر بیفتد. برای مثال، اگر آدرس یک vault، pool یا lending contract بلاک شود، آن قرارداد دیگر نمی‌تواند توکن را جابه‌جا کند یا به کاربران بازگرداند.

نمونه‌ی ساده:

 

mapping(address => bool) blocked;

function block(address usr) public auth { 

blocked[usr] = true; 

}
function transferFrom(address src, address dst, uint wad) 
public
override
returns (bool)
{

 require(!blocked[src], "blocked");

 require(!blocked[dst], "blocked"); 



return super.transferFrom(src, dst, wad); 

}

 

در این مثال، اگر src یا dst داخل blocklist باشد، انتقال revert می‌شود. یعنی حتی اگر قرارداد اصلی از نظر منطق خودش درست کار کند، باز هم ممکن است به دلیل policy خود توکن نتواند transfer انجام دهد.

سناریوی خطرناک این است که آدرس خود قرارداد بلاک شود. در این حالت، توکن‌هایی که داخل قرارداد قرار دارند ممکن است عملاً گیر کنند و عملیات‌هایی مانند withdrawal، repayment یا swap با شکست مواجه شوند.

 

7- Pausable Tokens

 

برخی توکن‌ها قابلیت pause دارند. یعنی admin توکن می‌تواند انتقال‌ها را به‌صورت موقت متوقف کند. در این حالت، توابعی مانند transfer، transferFrom یا حتی approve ممکن است تا زمانی که توکن pause است، revert شوند.

مشکل این رفتار شبیه blocklist است. اگر یک پروتکل با چنین توکنی کار کند، ممکن است در حالت عادی همه‌چیز درست باشد، اما پس از pause شدن توکن، عملیات‌هایی مانند برداشت، واریز، repay، swap یا liquidate از کار بیفتند.

نمونه‌ی ساده:

 

bool live = true; 

function stop() external auth { 
  live = false; 
}

function transfer(address dst, uint wad) 
public 
override 
returns (bool) 
{

 require(live, "paused");

 return super.transfer(dst, wad);

 }

 

در این مثال، اگر admin تابع stop را صدا بزند، انتقال توکن متوقف می‌شود و هر قراردادی که به transfer این توکن وابسته باشد ممکن است نتواند کارش را ادامه دهد.

سناریوی خطرناک این است که دارایی کاربران داخل یک pool، vault یا lending protocol باشد و توکن pause شود. در این حالت، کاربران ممکن است نتوانند دارایی خود را خارج کنند، حتی اگر خود پروتکل از نظر فنی مشکلی نداشته باشد.

 

8- Approval Race Protections

 

 

این بخش مربوط به یکی از مشکلات قدیمی و معروف در تابع approve توکن‌های ERC-20 است.

در ERC-20، وقتی کاربر می‌خواهد به یک قرارداد یا آدرس دیگر اجازه دهد از طرف او توکن خرج کند، از تابع approve استفاده می‌کند.

نمونه:

token.approve(router, 100);

 

یعنی کاربر به router اجازه می‌دهد تا سقف ۱۰۰ توکن از طرف او خرج کند.

حالا فرض کنید کاربر قبلاً به router مقدار ۱۰۰ توکن approve داده و بعد می‌خواهد این مقدار را به ۲۰۰ تغییر دهد:

token.approve(router, 200);

 

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

 

token.approve(router, 0);
token.approve(router, 200);

 

دلیل این رفتار، یک مشکل قدیمی به نام approve race condition است.

سناریوی خطرناک به این شکل است:

  1. کاربر قبلاً به spender مقدار ۱۰۰ توکن approve داده است.
  2. کاربر می‌خواهد allowance را از ۱۰۰ به ۲۰۰ تغییر دهد.
  3. spender تراکنش جدید کاربر را در mempool می‌بیند.
  4. spender قبل از ثبت approve جدید، همان ۱۰۰ توکن قبلی را خرج می‌کند.
  5. سپس approve جدید ثبت می‌شود و spender دوباره می‌تواند ۲۰۰ توکن خرج کند.

در بدترین حالت، به جای ۲۰۰ توکن، مجموعاً ۳۰۰ توکن قابل خرج شدن می‌شود.

برای کاهش این ریسک، برخی توکن‌ها مثل USDT اجازه نمی‌دهند allowance مستقیم از یک مقدار غیرصفر به یک مقدار غیرصفر دیگر تغییر کند.

نمونه‌ی این رفتار:

require(allowance[msg.sender][usr] == 0, "unsafe-approve");

 

یعنی اگر allowance قبلی برای آن spender صفر نباشد، approve جدید revert می‌شود.

مشکل برای پروتکل‌ها زمانی ایجاد می‌شود که قرارداد به شکل زیر نوشته شده باشد:

 

token.approve(router, amount);

 

اگر از قبل برای router مقدار allowance وجود داشته باشد، این call روی برخی توکن‌ها fail می‌شود. در نتیجه ممکن است عملیات‌هایی مانند repay، swap، deposit یا هر فرآیند دیگری که به approve نیاز دارد، revert شود.

برای همین هنگام آدیت باید بررسی شود که قرارداد چگونه approve انجام می‌دهد. اگر مستقیم مقدار جدید را approve می‌کند، ممکن است با برخی توکن‌ها سازگار نباشد.

روش بهتر این است که ابتدا allowance قبلی صفر شود و سپس مقدار جدید تنظیم شود؛ یا از helperهایی مانند forceApprove استفاده شود.

 

‫چک لیست‬‫ آدیت‬ ‫تعامل‬ ‫با‬ ‫توکن های‬ ‫‪ERC-20‬‬

 

هنگام بررسی یک قرارداد که با توکن‌های ERC-20 خارجی تعامل دارد، بهتر است موارد زیر بررسی شوند:

  • آیا مقدار برگشتی transfer، transferFrom و approve به‌درستی مدیریت می‌شود؟

  • آیا قرارداد از SafeERC20 یا wrapper امن مشابه استفاده می‌کند؟

  • آیا قرارداد فرض کرده مقدار دریافتی دقیقاً برابر با amount است؟

  • آیا balance قبل و بعد از انتقال در موارد حساس بررسی می‌شود؟

  • آیا تعامل با توکن می‌تواند مسیر reentrancy ایجاد کند؟

  • آیا توکن fee-on-transfer است؟

  • آیا توکن قابلیت pause دارد؟

  • آیا توکن دارای blocklist است؟

  • آیا توکن upgradable است؟

  • آیا منطق پروتکل به totalSupply یا balance لحظه‌ای اعتماد می‌کند؟

  • آیا توکن می‌تواند flash mint داشته باشد؟

  • آیا approve به‌صورت سازگار با توکن‌هایی مانند USDT انجام می‌شود؟

  • آیا پروتکل allowlist برای توکن‌های مجاز دارد؟

  • آیا شکست transfer باعث قفل شدن دارایی کاربران می‌شود؟


    جمع بندی‬

    استاندارد ERC-20 یکی از مهم‌ترین پایه‌های اکوسیستم اتریوم است و باعث شد ساخت و استفاده از توکن‌ها بسیار ساده‌تر و سازگارتر شود. اما همین سادگی، باعث شده است بسیاری از رفتارهای امنیتی مهم به‌صورت دقیق در استاندارد enforce نشوند.

    در دنیای واقعی، همه‌ی توکن‌های ERC-20 رفتار یکسانی ندارند. بعضی مقدار برگشتی ندارند، بعضی fee کم می‌کنند، بعضی قابل pause یا block هستند، بعضی قابلیت upgrade دارند و برخی حتی می‌توانند در طول یک تراکنش flash mint انجام دهند.

    بنابراین، در طراحی و آدیت قراردادهای هوشمند نباید با توکن خارجی مثل یک balance ساده رفتار کرد. هر تعامل با توکن می‌تواند یک external call، یک منبع ناسازگاری یا یک ریسک امنیتی باشد.

    قاعده‌ی کلی این است: قرارداد نباید به رفتار ایده‌آل توکن اعتماد کند؛ بلکه باید برای رفتارهای غیرمنتظره آماده باشد.

     

نظرات (0)

اولین نظر را شما بنویسید.

برای ثبت نظر وارد شوید.