امنیت در توکن های 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 ساده است تا یک استاندارد
سختگیرانهو دقیق.
به همین دلیل، بسیاری از توسعه دهندگان توکنها را با رفتارهای خاص و گاهی غیراستاندارد
پیاده سازی می کنند. در جامعه ی امنیت قراردادهای هوشمند، به این دسته از توکنها معموال ً
TokensERC20 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 ورودی اعتماد کرد؛ بهخصوص در عملیاتهایی مانند:
depositswaprepaylendingreward accountingliquidity 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 است.
سناریوی خطرناک به این شکل است:
- کاربر قبلاً به
spenderمقدار۱۰۰توکنapproveداده است. - کاربر میخواهد
allowanceرا از۱۰۰به۲۰۰تغییر دهد. spenderتراکنش جدید کاربر را درmempoolمیبیند.spenderقبل از ثبتapproveجدید، همان۱۰۰توکن قبلی را خرج میکند.- سپس
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، یک منبع ناسازگاری یا یک ریسک امنیتی باشد.قاعدهی کلی این است: قرارداد نباید به رفتار ایدهآل توکن اعتماد کند؛ بلکه باید برای رفتارهای غیرمنتظره آماده باشد.