Ethernaut writeup part 16 (DoubleEntryPoint)
มาลองใช้ Forta กันครับ
โอเค โจทย์คือ
“This level features a CryptoVault
with special functionality, the sweepToken
function. This is a common function used to retrieve tokens stuck in a contract. The CryptoVault
operates with an underlying
token that can't be swept, as it is an important core logic component of the CryptoVault
. Any other tokens can be swept.
The underlying token is an instance of the DET token implemented in the DoubleEntryPoint
contract definition and the CryptoVault
holds 100 units of it. Additionally the CryptoVault
also holds 100 of LegacyToken LGT
.
In this level you should figure out where the bug is in CryptoVault
and protect it from being drained out of tokens.
The contract features a Forta
contract where any user can register its own detection bot
contract. Forta is a decentralized, community-based monitoring network to detect threats and anomalies on DeFi, NFT, governance, bridges and other Web3 systems as quickly as possible. Your job is to implement a detection bot
and register it in the Forta
contract. The bot's implementation will need to raise correct alerts to prevent potential attacks or bug exploits.
Things that might help:
- How does a double entry point work for a token contract?”
โดยมี code ให้ตามนี้
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/access/Ownable.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
interface DelegateERC20 {
function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}
interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}
interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}
contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;
function setDetectionBot(address detectionBotAddress) external override {
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}
function notify(address user, bytes calldata msgData) external override {
if(address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}
function raiseAlert(address user) external override {
if(address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}
contract CryptoVault {
address public sweptTokensRecipient;
IERC20 public underlying;
constructor(address recipient) {
sweptTokensRecipient = recipient;
}
function setUnderlying(address latestToken) public {
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}
/*
...
*/
function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
}
contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
DelegateERC20 public delegate;
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
delegate = newContract;
}
function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
}
contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
address public cryptoVault;
address public player;
address public delegatedFrom;
Forta public forta;
constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) {
delegatedFrom = legacyToken;
forta = Forta(fortaAddress);
player = playerAddress;
cryptoVault = vaultAddress;
_mint(cryptoVault, 100 ether);
}
modifier onlyDelegateFrom() {
require(msg.sender == delegatedFrom, "Not legacy contract");
_;
}
modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));
// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);
// Notify Forta
forta.notify(player, msg.data);
// Continue execution
_;
// Check if alarms have been raised
if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}
function delegateTransfer(
address to,
uint256 value,
address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
_transfer(origSender, to, value);
return true;
}
}
เอาละมาเริ่มวิเคราะห์โจทย์กัน
จากประโยค
“ The CryptoVault
operates with an underlying
token that can't be swept, as it is an important core logic component of the CryptoVault
. Any other tokens can be swept.”
และ
“In this level you should figure out where the bug is in CryptoVault
and protect it from being drained out of tokens.”
แสดงว่าเราต้องทำ 2 ขั้นตอนคือ หา bug และ ป้องกันไม่ให้เกิดการโจมตีขึ้น
มาเริ่มจากการหา bug กันก่อน
โอเค ดีที่มี code foundry test จาก StErMi มาลอง log ค่าใน local network กัน
function setupLevel() internal override {
/** CODE YOUR SETUP HERE */
levelAddress = payable(this.createLevelInstance{value: 0.001 ether}(true));
level = DoubleEntryPoint(levelAddress);
// Check that the contract is correctly setup
}
function exploitLevel() internal override {
/** CODE YOUR EXPLOIT HERE */
vm.startPrank(player, player);
ERC20 LGT = ERC20(level.delegatedFrom());
ERC20 DET = ERC20(address(level));
CryptoVault cryptoVault = CryptoVault(level.cryptoVault());
console.log("cryptoVault.address ", address(cryptoVault));
console.log("LGT.balanceOf(cryptoVault) ", LGT.balanceOf(address(cryptoVault)));
console.log("DET.balanceOf(cryptoVault) ", DET.balanceOf(address(cryptoVault)));
vm.stopPrank();
}
CryptoVault มี 100 LGT และ 100 DET ตามที่โจทย์บอก
ซึ่งจริงๆแล้ว DET ที่เป็น underlying ของ Vault มีการป้องกันการถอนไว้
แต่เราพบว่า DET และ LGT มีความเชื่อมโยงกันตามรูป
โดย LGT จะจำ DET เป็น delegate address ทำให้เมื่อมีการสั่ง transfer LGT จะไปเรียก delegateTransfer ของ DET ต่อ
และในฝั่ง DET เมื่อมีการเรียก delegateTransfer มา จะทำการโอน DET ตามที่ร้องขอมาไปให้ … โดยมีการเช็คว่า delegateTransfer เรียกมาจาก LGT จริงมั้ย และละเมิด bot Forta ที่ตั้งไว้รึเปล่า
โอเค จากที่เล่ามา เราจะสังเกตได้ว่า
- เมื่อเราสั่งโอน LGT แล้ว DET จะถูกโอนแทน
- CryptoVault ไม่ควรจะถอน DET ได้ ควรจะถอนได้แต่ LGT
แต่การถอน LGT จะเป็นการสั่งโอน DET แทน ดังนั้นถ้าเราลอง ถอน LGT ตาม code
function exploitLevel() internal override {
/** CODE YOUR EXPLOIT HERE */
vm.startPrank(player, player);
ERC20 LGT = ERC20(level.delegatedFrom());
ERC20 DET = ERC20(address(level));
CryptoVault cryptoVault = CryptoVault(level.cryptoVault());
console.log("cryptoVault.address ", address(cryptoVault));
console.log("LGT.balanceOf(cryptoVault) ", LGT.balanceOf(address(cryptoVault)));
console.log("DET.balanceOf(cryptoVault) ", DET.balanceOf(address(cryptoVault)));
console.log("====");
cryptoVault.sweepToken(IERC20(address(LGT)));
console.log("LGT.balanceOf(cryptoVault) ", LGT.balanceOf(address(cryptoVault)));
console.log("DET.balanceOf(cryptoVault) ", DET.balanceOf(address(cryptoVault)));
vm.stopPrank();
}
โอเค เราพบ contract ของ Contract ที่ทำให้ DET ถูกถอนออกจาก vault ละ
คำถามต่อไปคือ
เราใช้ Forta ป้องกัน bug นี้ยังไงได้บ้าง ?
จาก flow bug ที่เกิดขึ้นเนี่ย มีการเรียก function คร่าวๆดังนี้
CryptoVault.sweepToken => LGT.transfer => DET.delegateTransfer
แต่ใน DET.delegateTransfer จะมีการเรียก forta.usersDetectionBots และ forta.botRaisedAlerts อยู่ ตามรูป
โดยถ้าหลังจากการเรียก DET.delegateTransfer แล้ว มีค่า forta.botRaisedAlerts มากขึ้น จะทำการ revert tx
โอเค เท่าที่ดู เราน่าจะเขียน logic ป้องกัน bug โดยใช้ forta.botRaisedAlerts ได้ งั้นมาดู code Forta กัน
contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;
function setDetectionBot(address detectionBotAddress) external override {
require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}
function notify(address user, bytes calldata msgData) external override {
if (address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}
function raiseAlert(address user) external override {
if (address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}
จาก code เพื่อเพิ่มค่า botRaisedAlerts เราจะต้องทำการเรียก Forta.raiseAlert เข้ามาจาก contract ภายนอก โดยเมื่อนำ flow contract มารวมกันจะได้ดังนี้
CryptoVault.sweepToken => LGT.transfer => DET.delegateTransfer => Forta.notify => FortaBot.handleTransaction
โดย FortaBot.handleTransaction เป็น external call และเราสามารถเขียนและ plug ตัว FortaBot เข้าไปได้เอง ซึ่งในการป้องกัน bug นั้น เราต้อง detect tx pattern ที่ต้องการใน FortaBot.handleTransaction แล้วทำการเรียก Forta.raiseAlert เพื่อให้ revert นั่นเอง
เอาละ งั้นมาเขียน FortaBot กัน
โดยมี IDetectionBot เป็น guideline ให้คือ
interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}
โอเคใน handleTransaction เราต้อง
- detect การ DET.delegateTransfer ที่มี parameter คือ to = ตัว player เอง (เอาจริงๆคือ address ไหนก็ได้เพราะเป็นการ drain vault เหมือนกัน),value = 100 (เอาจริงๆอะไรก็ได้ที่ไม่เท่ากับ 0), และ origSender คือ Vault
- notify ไปยัง Forta โดยบันทึก alert ในนาม player
- register bot เข้ากับ DET
มาทำไปทีละขั้นกัน
การ detect การ DET.delegateTransfer ที่มี parameter ที่ match กับ pattern bug และการ notify ไปยัง Forta โดยบันทึก alert ในนาม player
ขอละการเช็คให้เหลือแค่เช็คการเรียก DET.delegateTransfer ที่มี origSender = vault ละกัน
โดยการ msg.data ที่ส่งมาตั้งแต่ forta.notify
เราจะแกะ parameter ออกมาจาก msg.data ยังไง?
โอเค msg.data เนี่ยได้มาจากการเรียก function ตั้งแต่ต้นทางของ EOA โดยเมื่อกลายเป็น byte แล้ว จะมี 4 byte แรกเป็นชื่อฟังชัน และที่เหลือเป็น parameter
ซึ่งถ้าเรารู้จำนวน และ type ของ parameter เราสามารถใช้แกะ parameter ที่ต้องการออกมาได้ดังนี้
function handleTransaction(address user, bytes calldata msgData) external override {
(address to, uint256 value, address origSender) = abi.decode(msgData[4:], (address, uint256, address));
if (
origSender == <vault_address>
) {
IForta(msg.sender).raiseAlert(user);
}
}
แต่ท่านี้ทำได้แค่การเช็คค่า parameter ถ้าเราอยากเช็คด้วยว่า msg.data มาจากการเรียก delegateTransfer จริงๆมั้ย สามารถเขียนได้เพิ่มดังนี้
function handleTransaction(address user, bytes calldata msgData) external override {
(address to, uint256 value, address origSender) = abi.decode(msgData[4:], (address, uint256, address));
bytes memory callSig = abi.encodePacked(msgData[0], msgData[1], msgData[2], msgData[3]);
if (
origSender == <vault_address> &&
keccak256(callSig) == keccak256(abi.encodeWithSignature("delegateTransfer(address,uint256,address)"))
) {
IForta(msg.sender).raiseAlert(user);
}
}
โอเค ไปขั้นต่อไปกัน
Register bot เข้ากับ DET
ตัว instance forta ถูกผูกเข้ากับ DET อยู่แล้ว
และใน forta ก็เปิดให้ registerBotContract ผ่านทาง forta.setDetectionBot
ดังนั้นเราสามารถประกอบ code ได้ดังนี้
contract TestDoubleEntryPoint is BaseTest {
DoubleEntryPoint private level;
constructor() public {
// SETUP LEVEL FACTORY
levelFactory = new DoubleEntryPointFactory();
}
function setUp() public override {
// Call the BaseTest setUp() function that will also create testsing accounts
super.setUp();
}
function testRunLevel() public {
runLevel();
}
function setupLevel() internal override {
/** CODE YOUR SETUP HERE */
levelAddress = payable(this.createLevelInstance{value: 0.001 ether}(true));
level = DoubleEntryPoint(levelAddress);
// Check that the contract is correctly setup
}
function exploitLevel() internal override {
/** CODE YOUR EXPLOIT HERE */
vm.startPrank(player, player);
DetectionBot bot = new DetectionBot(level.cryptoVault());
level.forta().setDetectionBot(address(bot));
vm.stopPrank();
}
}
contract DetectionBot is IDetectionBot {
address private monitoredSource;
constructor(address _monitoredSource) public {
monitoredSource = _monitoredSource;
}
function handleTransaction(address user, bytes calldata msgData) external override {
(address to, uint256 value, address origSender) = abi.decode(msgData[4:], (address, uint256, address));
bytes memory callSig = abi.encodePacked(msgData[0], msgData[1], msgData[2], msgData[3]);
if (
origSender == monitoredSource &&
keccak256(callSig) == keccak256(abi.encodeWithSignature("delegateTransfer(address,uint256,address)"))
) {
IForta(msg.sender).raiseAlert(user);
}
}
}
โอเค ข้อนี้ประมาณนี้ละนะ
ได้ base code มาจาก StErMi ซะเยอะเลย Thank you