Ethereum Storage wiki部分
Balsn CTF 2019 这题考察了挺多东西,Ethereum Storage的存储,局部变量未初始化导致可覆盖slot,控制程序流进行跳转有点像pwn了。下面两篇文章讲的非常之详细了。
参考wp 参考wp2
合约源码 较题目源码增加了一个read_slot函数方便读slot值和读safeboxes数组内容的函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 pragma solidity ^0.4.24; contract Bank { event SendEther(address addr); event SendFlag(address addr); address public owner; uint randomNumber = 0; constructor() public payable{ owner = msg.sender; } struct SafeBox { bool done; function(uint, bytes12) internal callback; bytes12 hash; uint value; } SafeBox[] safeboxes; struct FailedAttempt { uint idx; uint time; bytes12 triedPass; address origin; } mapping(address => FailedAttempt[]) failedLogs; modifier onlyPass(uint idx, bytes12 pass) { if (bytes12(sha3(pass)) != safeboxes[idx].hash) { FailedAttempt info; info.idx = idx; info.time = now; info.triedPass = pass; info.origin = tx.origin; failedLogs[msg.sender].push(info); } else { _; } } function deposit(bytes12 hash) payable public returns(uint) { SafeBox box; box.done = false; box.hash = hash; box.value = msg.value; if (msg.sender == owner) { box.callback = sendFlag; } else { require(msg.value >= 1 ether); box.value -= 0.01 ether; box.callback = sendEther; } safeboxes.push(box); return safeboxes.length-1; } function withdraw(uint idx, bytes12 pass) public payable { SafeBox box = safeboxes[idx]; require(!box.done); box.callback(idx, pass); box.done = true; } function box_info(uint i) public returns(uint,bytes32){ return(safeboxes.length,safeboxes[i]) } function sendEther(uint idx, bytes12 pass) internal onlyPass(idx, pass) { msg.sender.transfer(safeboxes[idx].value); emit SendEther(msg.sender); } function sendFlag(uint idx, bytes12 pass) internal onlyPass(idx, pass) { require(msg.value >= 100000000 ether); emit SendFlag(msg.sender); selfdestruct(owner); } function read_slot(uint k) public view returns (bytes32 res) { assembly { res := sload(k) } } }
合约分析 分析我就不写了,上面两篇wp真的非常详细。 主要有几个点:
deposit里的 SafeBox box,对box的声明并没有指定位置和初始化,所有该结构体指针是指向slot0的,而存储owner的地方正是在slot0,可以覆盖owner,不过后面限制了require(msg.value >= 100000000 ether)
所以没啥用。
这个modifier里声明的 FailedAttempt info; 也是未指定位置和初始化的,所以也能覆盖。它能覆盖slot0-2,所以可以改safeboxes的长度。
afeboxes是一个动态数组,failedLogs 是一个映射,但他们都是存储在storage上的,所以有没有可能,他们是可以重叠的。只要safeboxes的长度比他们各自起始位置的差值的二分之一大就可以了。也就是 keccak245(msg.address()||3) -keccak256(2) < safebox.length // 2 (因为一个box占俩slot)
重叠之后 failedLogs 里的 某个 info 通过修改 triedPass 就能覆盖safeboxes里某个box的callback了。直接给他跳到 emit SendFlag(msg.sender)
storage具体结构 覆盖又有两种情况,分别如下。
合约攻击 简单记录一下解题过程。不过题目局限于solidity<0.5中
计算target = keccak256(keccak256(msg.sender||3)) + 2,这个是 failedLogs [msg.sender].”origin+tridPasss”的地方,我们要改这里[注意这里有两次keccak,一次是mapping的,一次是failedLogs[]的,实际部署的时候在这里踩坑了] 利用如下合约可以进行获取 slot 内容、mapping 内容对应 slot 、数组第一个元素对应 slot。
target = 0 xb1 a6110 cd4944 e459 f5 cf3 db076879119 a1 a19 d3 b554 e562 f2 e2 cfbad7 f41 d06
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 pragma solidity ^0.4.24; contract calc{ function read_slot(uint k) public view returns (bytes32 res) { assembly { res := sload(k) } } function cal_addr(uint k, uint p) public pure returns(bytes32 res) { res = keccak256(keccak256(abi.encodePacked(k, p))); } function calc_1(uint k, uint p) public pure returns(bytes32 res) { res = keccak256(abi.encodePacked(k, p)); } function cal_addr(uint p) public pure returns(bytes32 res) { res = keccak256(abi.encodePacked(p)); } function cal_uint(uint p) public pure returns(bytes32 res) { res = keccak256(p); } }
计算base = keccak256(2),这个是safeboxes的起始位置
base = 0 x405787 fa12 a823 e0 f2 b7631 cc41 b3 ba8828 b3321 ca811111 fa75 cd3 aa3 bb5 ace
计算idx = (target-base)//2 (一个box占俩slot) 这个是要改的位置和safeboxes开始的位置之间能塞多少个box。
这里 idx 指的是在 safeboxes 数组中的索引,因为一个 Safebox 占据两个 slot ,所以要除以 2
idx = 0 x38 a7448960 f615325652 c85 f21 a69 eb48 bc77358 f569 ea287 c3681401 a1 c611 c
如果 (target-base) % 2 == 1
,说明不是正正好塞满整数个box,那么idx += 2,我们要用到下两个box,这个box和下一个box都改不到。如果(target - base) % 2
,如果等于 0 ,说明 origin(20) | triedPass(12) 刚好可以覆盖 unused (11) | hash (12) | callback (8) | done (1)
可以看到这里恰好能塞满整数个box。
计算result = (msg.sender << (12*8))
,如果result < idx
得换一个账户,长度无法达到覆盖的要求,因为safeboxes的长度是用tx.origin去覆盖的,最后的值会是tx.origin <<(12*8)+Pass
经过判断,(msg.sender << (12*8))>idx,符合长度要求。
result = 0 x9 c5 d5 be2 a76503957853 d9 b6 f81 ffde226635739000000000000000000000000
用 1 eth 调用一下 deposit(0x000000000000000000000000)。主要是让box.callback = sendEther;然后就能通过withdraw调用sendEther函数了。
调用完查看slot,可以发现第一个slot中有一个0x08E7,反编译合约查看在0x08E7有一个跳转,就是sendEther函数调用。
调用 withdraw(0, 0x111111111111110000084300),将callback函数覆盖为0843
,再调用callback即可跳转到SendFlag哈数。如果step4中 (target-base) % 2 == 1,那么这一步执行两次
0x52b7d2dcc80cd2e4000000即为100000000 eth
调用完withdraw 查看storage,发现safebox长度确实被覆盖了(值即为msg.sender + 0x111111111111110000084300)
并且查看slot=target
处的值,成功覆盖的话应该会是msg.sender + 0x111111111111110000084300
,事实确实如此
最后再调用一下withdraw(idx, 0x000000000000000000000000) 就能触发emit SendFlag(msg.sender);事件了。
XCTF_final 2019 Happy_DOuble_Eleven