Reentrancy केवल तभी हो सकती है जब आपका smart contract किसी अन्य smart contract को function call या ether भेजकर कॉल करता है।
यदि आप execution के बीच में किसी अन्य contract को कॉल नहीं करते हैं या ether नहीं भेजते हैं, तो आप execution control नहीं सौंप सकते, और reentrancy नहीं हो सकती।
function proxyVote(uint256 voteChoice) external {
voteContract.vote(voteChoice); // hands control to voteContract
alreadyVoted = true;
}
सबसे मुश्किल हिस्सा यह है कि आपको हमेशा यह पता नहीं होता कि आप किसी अन्य contract को कब कॉल कर रहे हैं। उदाहरण के लिए, यदि यह कोड किसी ERC1155 contract के अंदर उपयोग किया जाता है, तो यह वास्तव में re-entrant है।
function purchaseERC1155NFT() external {
_mint(msg.sender, TOKEN_ID, 1, "");
erc20Token.transferFrom(msg.sender, address(this));
}
यह हानिरहित (innocuous) दिखने वाला mint असुरक्षित क्यों है? आइए OpenZeppelin ERC1155 में कोड देखें यहाँ।
function _mint(
address to,
uint256 id,
uint256 amount,
bytes memory data
) internal virtual {
require(to != address(0), "ERC1155: mint to the zero address");
address operator = _msgSender();
uint256[] memory ids = _asSingletonArray(id);
uint256[] memory amounts = _asSingletonArray(amount);
_beforeTokenTransfer(operator, address(0), to, ids, amounts, data);
_balances[id][to] += amount;
emit TransferSingle(operator, address(0), to, id, amount);
_afterTokenTransfer(operator, address(0), to, ids, amounts, data);
_doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount, data);
}
Solidity code ERC1155
_mint, _doSafeTransferAcceptanceCheck को कॉल करता है। आइए उस function को देखते हैं।
function _doSafeTransferAcceptanceCheck(
address operator,
address from,
address to,
uint256 id,
uint256 amount,
bytes memory data
) private {
if (to.isContract()) {
try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) {
if (response != IERC1155Receiver.onERC1155Received.selector) {
revert("ERC1155: ERC1155Receiver rejected tokens");
}
} catch Error(string memory reason) {
revert(reason);
} catch {
revert("ERC1155: transfer to non-ERC1155Receiver implementer");
}
}
}
Solidity code IERC1155Receiver
और वहाँ हम देख सकते हैं कि _mint अंततः receiving function पर एक function onERC1155Received को कॉल करने का प्रयास करेगा। अब हमने एक अन्य contract को control सौंप दिया है।
टूल slither स्वचालित रूप से external function calls का पता लगा लेगा, इसलिए आपको इसका उपयोग करना चाहिए।
उम्मीद है, इससे चीजें अधिक भ्रमित करने वाली नहीं होंगी, लेकिन एक बहुत ही समान दिखने वाला कोड
function purchaseERC1155NFT() external {
_mint(msg.sender, AMOUNT);
erc20Token.transferFrom(msg.sender, address(this));
}
re-entrant नहीं है यदि यह ERC20 से लिया (derived) गया है। ऐसा इसलिए है क्योंकि बैकग्राउंड में (under the hood), Solidity में transferFrom function किसी external function को function call नहीं करता है, जैसा कि आप इसके implementation में देख सकते हैं।
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(from, to, amount);
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] += amount;
}
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}
ERC20 transfer implementation
ERC721
safeTransferFrom_safeMint
भ्रमित करने वाली बात यह है कि “safe” शब्द का अर्थ है कि यह जांच कर रहा है कि क्या receiving address एक smart contract है, फिर यह “onERC721Received” function को कॉल करने का प्रयास करता है। transferFrom और _mint functions ऐसा नहीं करते हैं, इसलिए आपको reentrancy के बारे में चिंता करने की आवश्यकता नहीं है।
इसका मतलब यह नहीं है कि आपको safeTransferFrom या _safeMint विधियों (methods) का उपयोग नहीं करना चाहिए, इसका मतलब है कि यदि आप इसका उपयोग करते हैं तो reentrancy को रोकने के लिए आपको check-effects pattern या reentrancy guards का उपयोग करना चाहिए।
यहाँ mint function का एक सरल उदाहरण दिया गया है जहाँ attacker अपने लिए सभी NFTs को mint कर सकता है:
contract FooToken is ERC721 {
function mint() external payable {
require(msg.value == 0.1 ether);
require(!alreadyMinted[msg.sender]);
totalSupply++;
_safeMint(msg.sender, totalSupply);
alreadyMinted[msg.sender] = true;
}
}
ERC1155
safeTransferFrom_mintsafeBatchTransferFrom_mintBatch
इससे भी अधिक भ्रमित करने वाली बात यह है कि ERC1155 में _mint, ERC721 में _mint की तरह व्यवहार नहीं करता है। यह ERC721 में _safeMint की तरह व्यवहार करता है।
ERC1155 में कुछ भी “safe” नहीं है। प्रत्येक method receiving contract को कॉल करता है। इस डिज़ाइन विकल्प (design choice) में कुछ भी गलत नहीं है, इसका सीधा सा मतलब है कि आपको check-effects pattern का पालन करना चाहिए या reentrancy guards का उपयोग करना चाहिए — जैसा कि आपको वैसे भी करना चाहिए।
यहाँ ERC1155 के लिए vulnerable कोड है
contract FooToken is ERC1155 {
function mint(uint256 tokenId) external payable {
require(msg.value == 0.1 ether);
require(!alreadyMinted[msg.sender]);
totalSupplyForTokenId[tokenId]++;
_mint(msg.sender, totalSupplyForTokenId[tokenId], 1, "");
alreadyMinted[msg.sender] = true;
}
}
ERC 223, 677, 777, and 1363
हम यहाँ ERC20 के प्रत्येक प्रस्तावित variation को कवर नहीं कर सकते हैं। यह तथ्य (fact) कि ERC20 transfer और transferFrom के परिणामस्वरूप reentrancy नहीं होती है, बहुत अच्छा है, लेकिन यह UX समस्याएँ भी पैदा करता है जहाँ एक smart contract को यह पता नहीं चल पाता कि उसे ERC20 token प्राप्त हुआ है। उपरोक्त सूची ERC20 के कुछ प्रस्तावित variations हैं जो receiving smart contract को सूचित करने का प्रयास करते हैं कि उन्हें tokens प्राप्त हुए हैं।
untrusted ERC20 tokens के साथ interact करते समय यह एक चेतावनी भी होनी चाहिए। वे वास्तव में आंतरिक रूप से (under the hood) इनमें से कोई एक standard हो सकते हैं और reentrancy को trigger करने में सक्षम हो सकते हैं।
यहाँ वह लाइन दी गई है जहाँ ERC777 tokens को transfer करने के बाद contract को कॉल करता है: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.8/contracts/token/ERC777/ERC777.sol#L499
function _callTokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData,
bool requireReceptionAck
) private {
address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(to, _TOKENS_RECIPIENT_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Recipient(implementer).tokensReceived(operator, from, to, amount, userData, operatorData);
} else if (requireReceptionAck) {
require(to.isContract(), "ERC777: token recipient contract has no implementer for ERC777TokensRecipient");
}
}
Solidity ERC777 reentrancy line
ERC 1363 का इसके लिए एक बेहतर UX है। नियमित transfer function एक सामान्य ERC20 की तरह व्यवहार करता है, इसलिए हमें कोई छिपी हुई (sneaky) reentrancy समस्याएँ नहीं मिलती हैं। हालाँकि, यदि हम contract को सचेत करना चाहते हैं कि उसने tokens प्राप्त किए हैं, तो हम transferAndCall विधि का उपयोग करते हैं।
वास्तविक दुनिया में ERC777 reentrancy हुई है और यह काफी विनाशकारी हो सकती है। यहाँ एक उदाहरण है।
जब कोई ऐसा एप्लिकेशन डिज़ाइन कर रहे हों जो arbitrary ERC20 tokens के साथ interact करता है, तो यह न मानें कि transfer और transferFrom non-reentrant हैं।
Sending Ether
जब आप address.call(””) के माध्यम से ether भेजते हैं, तो आप control अन्य contract को सौंप देते हैं।
निम्नलिखित क्लासिक उदाहरण पर विचार करें
contract FaultyBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
msg.sender.call{value: balances[msg.sender]}("");
balances[msg.sender] = 0;
}
}
इस पर इस तरह से हमला किया जा सकता है
contract RobTheBank {
IFaultyBank private bank;
constructor(IFaultyBank _bank) {
bank = _bank;
}
function attack() payable {
bank.deposit{value: 1 ether}()
bank.withdraw();
}
fallback() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // reenterancy attack here
}
}
}
चूँकि balance भेजने के बाद balances[msg.sender] शून्य पर सेट हो जाता है, इसलिए attacker 1 ether (अन्य उपयोगकर्ताओं से चोरी करते हुए) निकालना जारी रख सकता है, जब तक कि balance 1 ether से कम न हो जाए।
transfer और send किस प्रकार reentrancy को रोकते हैं, और आपको उनका उपयोग क्यों नहीं करना चाहिए
एक अलग बात (As an aside), transfer() और send() विधियाँ re-entrant नहीं हैं, हालाँकि वे fallback और receive functions को trigger कर सकती हैं। ऐसा इसलिए है क्योंकि वे आगे भेजी गई gas को 2300 gas तक सीमित कर देते हैं। यह malicious contract के लिए victim contract में फिर से प्रवेश (re-enter) करने के लिए पर्याप्त नहीं है।
हालाँकि, आमतौर पर इन विधियों का उपयोग करना खराब अभ्यास (bad practice) माना जाता है। मान लीजिए कि आपके पास एक smart contract है जो किसी अन्य smart contract में loan चुकाने का प्रयास करता है। यदि आप transfer या send के साथ loan चुकाते हैं, तो lending contract के पास यह register करने के लिए पर्याप्त gas नहीं होगी कि loan चुका दिया गया था।
2016 का DAO हैक Ethereum इकोसिस्टम के लिए लगभग घातक (fatal) था, इसलिए डिज़ाइनर्स ने इसे होने से रोकने के लिए इन functions को पेश किया।
Transfer और send का उपयोग किए जाने पर वे केवल 2300 gas ही आगे भेजते हैं। जब 2300 से कम gas उपलब्ध होती है तो Ethereum variable storage की अनुमति नहीं देता है (source), तो इसका मतलब है कि हमला करने वाला contract कोई स्थायी state change नहीं कर सकता है।
transfer और send के साथ समस्या यह है कि कई contracts जानबूझकर ether प्राप्त करने पर react करना चाह सकते हैं। उदाहरण के लिए, मान लें कि आपके पास एक decentralized lender है, और आप Ether भेजकर lender को वापस भुगतान करना चाहते हैं। lender contract देखता है कि ether borrower से आ रहा है, और उनके loan को paid के रूप में चिह्नित करता है। हालाँकि, यदि आप इसे gas की कमी (starve) कर देते हैं तो यह ऐसा नहीं कर सकता। आप इस बारे में अधिक पढ़ सकते हैं कि आपको इन functions का उपयोग क्यों नहीं करना चाहिए यहाँ।
यह अजीब लग सकता है कि Solidity में ऐसे features हैं जिनका आपको उपयोग नहीं करना चाहिए, लेकिन यह blockchain best practices की हमारी विकसित होती समझ का हिस्सा है। गैस को सीमित करके reentrancy को रोकना उस समय एक अच्छा विचार लग रहा था, लेकिन यह पता चला है कि हम यह अनुमान नहीं लगा सकते कि भविष्य में gas costs क्या होंगी। गैस को Hardcode करना खराब अभ्यास माना जाता है, क्योंकि opcodes की gas value बदल सकती है (has changed)।
Cross-function reentrancy. Reentrancy को उसी function में प्रवेश करने की आवश्यकता नहीं है
जब victim contract गलत समय पर external contract को function call करता है, तो हमला करने वाले contract को ज़रूरी नहीं कि उसी function में फिर से प्रवेश (re-enter) करना पड़े जिसने उसे कॉल किया था। वास्तव में, यदि दो functions re-entrant हैं, तो हमलावर functions के बीच “trampoline” (जिसे mutual recursion भी कहा जाता है) कर सकता है। कुछ इंजीनियर इसे cross-function reentrancy कहते हैं। यहाँ एक contract का उदाहरण दिया गया है जो इसके प्रति vulnerable है।
contract CrossFunctionReentrancyVulnerable {
// don't allow people to swap more than once every 24 hours
mapping(address => uint256) public lastSwap;
function swapAForB() {
require(block.timestamp - lastSwap[msg.sender] >= 1 days);
governanceTokenERC20.mint(msg.sender, AMOUNT);
tokenAerc777.transferFrom(msg.sender, address(this));
tokenBerc777.transferFrom(address(this), msg.sender);
lastSwap[msg.sender] = block.timestamp;
}
function swapBForA() {
require(block.timestamp - lastSwap[msg.sender] >= 1 days);
governanceTokenERC20.mint(msg.sender, AMOUNT);
tokenBerc777.transferFrom(msg.sender, address(this));
tokenAerc777.transferFrom(address(this), msg.sender);
lastSwap[msg.sender] = block.timestamp;
}
}
उपरोक्त कोड में, उपयोगकर्ता token A को B (और इसके विपरीत) के लिए swap कर सकते हैं और उन्हें governance tokens से पुरस्कृत किया जा सकता है। हालाँकि contract उन्हें हर 24 घंटे में स्वैपिंग तक सीमित करने का (प्रयास) करता है ताकि governance tokens बहुत तेज़ी से mint न हो जाएँ।
जैसा कि पहले बताया गया है, ERC777 tokens reentrant हो सकते हैं, लेकिन एक function पर साधारण reentrancy करने से काम नहीं चलेगा क्योंकि हमलावर के पास tokenA या tokenB खत्म हो जाएगा।
हालाँकि, यदि हमलावर बार-बार A को B के लिए swap करता है, तो वे अपने लिए सभी governance tokens को mint कर सकते हैं।
इस मामले में, हमने governance token को ERC20 token बना दिया है ताकि हमलावर उसी function में फिर से प्रवेश (reenter) न कर सके। हालाँकि, जब transferFrom(address(this), msg.sender) execute होता है, तो lastSwap mapping के अपडेट होने से पहले ही हमलावर control प्राप्त कर लेता है।
Read only Reentrancy, जिसे cross contract reentrancy भी कहा जाता है
Read only reentrancy 2022 में लोकप्रिय डेवलपर्स के दिमाग में तब आई जब ETH Devcon में एक वार्ता (talk) ने Curve finance में एक vulnerability को समझाया।
Read only reentrancy पहले से ज्ञात vulnerability, cross contract reentrancy का सिर्फ एक रीब्रांड (rebrand) है।
यदि contract Foo किसी अन्य contract Bar की state पर निर्भर करता है, और Bar mid-transaction में सही state values उत्पन्न नहीं करता है, तो Foo को धोखा दिया जा सकता है।
Curve finance मामले में, यह Curve नहीं था जिसका exploit किया गया था। वे contracts थे जो इस पर निर्भर थे। यह मोटे तौर पर इस तरह काम करता है:
- हमलावर curve में ether और अन्य ERC20 tokens जमा (deposit) करता है। Curve हमलावर को liquidity tokens mint करता है।
- हमलावर liquidity tokens को burn करके liquidity वापस निकाल (withdraw) लेता है।
- Curve ERC20 tokens वापस भेजने से पहले ether वापस भेजता है।
- जब curve Ether वापस भेजता है, तो हमलावर नियंत्रण (control) प्राप्त कर लेता है और किसी अन्य contract पर ट्रेड करता है।
- वह contract जो curve पर निर्भर है, liquidity tokens, ether और अन्य ERC20 tokens के बीच price ratio के लिए curve से पूछता है। चूंकि liquidity tokens burn कर दिए गए हैं, और Ethereum हमलावर को वापस कर दिया गया है, लेकिन ERC20 tokens अभी भी Curve में हैं, इस सटीक समय (state in time) पर prices की गणना गलत होती है।
- transaction पूरा हो जाता है, और Curve ERC20 tokens वापस भेजता है, और calculated price अब सही हो जाता है।
Read only reentrancy एक flash loan attack के बहुत समान है, और प्रभावी होने के लिए आमतौर पर एक flashloan की आवश्यकता होती है।
read only reentrancy या cross contract reentrancy से बचाव के दो तरीके हैं। एक है reentrancy lock को public करना या view functions को भी non-reentrant बनाना। वह view function जो prices की रिपोर्ट करता है, उस क्षण गलत state में होता है जब उपयोगकर्ता liquidity का एक हिस्सा निकालता है। इसलिए exchange लोगों को view function का उपयोग करने से रोक (block) सकता है जब liquidity निकाली जा रही हो। यदि reentrancy lock public है, तो एक एप्लिकेशन जो view function पर निर्भर करता है, reentrancy lock की जाँच करके यह जाँच सकता है कि क्या liquidity withdrawal प्रगति (in progress) पर है। यदि ether बाहर भेज दिया गया है, लेकिन ERC20 tokens अभी तक नहीं निकाले गए हैं, तो reentrancy lock ऑन (on) रहेगा क्योंकि withdraw liquidity function अभी तक पूरा नहीं हुआ है।
ध्यान दें कि इस vulnerability के लिए assets के एक क्रम (sequence) को भेजने की आवश्यकता होती है जो अन्य functions को trigger कर सकता है। ऊपर बताए गए Curve मामले में, उन्होंने ERC20 tokens भेजने से पहले Ether भेजा। हालाँकि, ऐसा ही कुछ तब हो सकता है जब ERC777 tokens भेजे गए हों।
अधिक संसाधन (More Resources)
वास्तविक दुनिया (in the wild) में reentrancy attacks की अद्यतन सूची:
https://github.com/pcaversaccio/reentrancy-attacks
cross-contract reentrancy (read-only reentrancy) पर 2022 से पहले का documentation
https://inspexco.medium.com/cross-contract-reentrancy-attack-402d27a02a15
अभ्यास (Practice exercises):
ERC 223 reentrancy:
https://capturetheether.com/challenges/miscellaneous/token-bank/
Ethernaut:
https://ethernaut.openzeppelin.com/level/10
(ध्यान दें कि यह reentrancy attack Solidity 0.8.0 या उच्चतर पर काम नहीं करता है क्योंकि balance को underflow करने से transaction revert हो जाएगा)
अधिक जानने में रुचि रखते हैं? हमारे Solidity Bootcamp को देखें!
मूल रूप से 16 दिसंबर, 2022 को प्रकाशित