前言
这两题一题是由Balsn战队举办的比赛中出现的,另一题则是在钓鱼城杯中利用相似的原理出的。总的来说是非常的有趣,最近抽出了一段时间好好感受了一下题目。
首先鸣谢两位师傅:
Pikachu师傅
Ainevsia师傅
在很大的程度上帮助我理解了题目。
题目考点主要在于理解动态数组、结构体以及mapping的存储方式,还有结构体中函数的存储。
首先我们给出钓鱼城这题的源码
pragma solidity ^0.4.23;
contract StrictMathematician {
address owner;
string private constant welcome = "Oh, fantansitic baby! I am a strict mathematician";
uint randomNumber = 0;
uint createtime = now;
constructor() public payable{
owner = msg.sender;
}
struct Target {
function() internal callback;
uint32 value;
address origin;
address sender;
bytes12 hash;
uint time;
}
Target[] Targets;
struct FailLog {
uint idx;
address origin;
uint time;
bytes12 guessnum;
address sender;
}
mapping(address => FailLog[]) FailLogs;
event SendFlag(address addr);
function start(bytes12 hash) public payable {
Target target;
target.origin = tx.origin;
target.sender = msg.sender;
target.hash = hash;
require(msg.value == 1 ether);
target.value += 1;
Targets.push(target);
}
function guess(uint idx, bytes12 num) public {
if (bytes12(keccak256(abi.encodePacked(num))) != Targets[idx].hash) {
FailLog faillog;
faillog.idx = idx;
faillog.time = now;
faillog.origin = tx.origin;
faillog.sender = msg.sender;
faillog.guessnum = num;
FailLogs[msg.sender].push(faillog);
} else {
Target target = Targets[idx];
target.value += 1;
}
}
function check(uint idx, uint tmp) public {
uint maxlen = check_len(address(msg.sender)) + tmp * 3 / 4 ;
require(uint(read_slot(uint(cal_mapaddr(uint(msg.sender),4)))) <= maxlen);
require(tmp != 0);
Target target = Targets[idx+tmp];
require(uint32(target.value+1)==0);
target.callback();
}
function payforflag() public payable {
require(address(this).balance == 0);
emit SendFlag(msg.sender);
selfdestruct(msg.sender);
}
function read_slot(uint k) internal view returns (bytes32 res) {
assembly { res := sload(k) }
}
function cal_mapaddr(uint k, uint p) internal pure returns(bytes32 res) {
res = keccak256(abi.encodePacked(k, p));
}
function cal_arrayaddr(uint p) internal pure returns(bytes32 res) {
res = keccak256(abi.encodePacked(p));
}
function check_len(address addr) internal pure returns(uint maxlen){
uint res = uint(cal_arrayaddr(uint(cal_mapaddr(uint(addr),4))));
uint sum = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
uint begin = uint(keccak256(abi.encodePacked(uint(3))));
uint distance;
uint remainder;
if (res>begin) {
distance = res - begin;
} else{
distance = sum - begin + res + 1;
}
remainder = distance % 3;
if (remainder==0) {
maxlen = 1;
} else if (remainder==1) {
maxlen = 3;
} else {
maxlen = 2;
}
}
}
Solidity在0.5以下都有因为结构体未初始化覆盖存储的漏洞。那么这里首先我们就可以看到两个结构体都会造成不同程度的覆盖,首先我们可以画出如下的storage地址结构。
若造成覆盖那么
我们可以看到这里是比较有趣的。 他里面的内存排布是非常重要的。所以最开始我有一个点没有看懂,后来在pikachu师傅的指点下才明白过来。
上面是进行赋值前的 slot0 和 赋值后的
在变化的时候是
通过这个就可以比较明显的看出来了,原来我一直以为是0初始值(x
然后我们通过审计源码可以发现,触发flag的要素是需要我们的合约清空,但是没有任何可以转账出去的函数,这就很迷奇了。但是我们可以发现,因为变量覆盖的原因。
它可以做到覆盖我们的Target结构体的长度。那么这样我们相当于实现了一个Target数组的任意写(实质上并不是得计算排布)。然后还有一个比较奇妙的点就是我们结构体中的callback()函数,这里我引用Ainevsia师傅博客里的一段
这种function类型的变量占据8个字节,就像C语言里的函数指针一样,调用这个变量所指向的函数的时候会使用JUMP指令跳转到该变量所表示的地址上。
这里提及一个其他知识点,Solidity中如果想Jump目标地址后面必须有Jumpdest,否则就会停止。这个考点在JOP类型题中比较关键。用于构造类似pwn中rop链的一种exp。题目中事件后是跟着一个Jumpdest无须多虑
那我们可以想到通过把event 那个事件的指针覆盖到我们的callback指针上,通过ida-evm插件的观察可以看到他的地址是0x0153 , 也就是需要将他覆盖上,然后唯一能对结构体做一定手脚的就是check函数,
有一个这样的条件,我们可以考虑让 FailLog和一段Target互相重叠达成如下图所示的效果。
那么这样我们可以传一个 0xffffffff0000000000000153 这样就可以把这个部署上去了。
然后就是经典的对需要写的目标地址进行计算
target = kecaak256(keccak256(abi.encode(addr,4)))+3
base = keccak256(3)
distance = (2^256-base+target) % (2^256), idx = distance // 3
然后就是要慢慢调整我们的结构达成上一个图的模式,最后调用check他callback的时候就会成功调用我们所覆写的地址了。从而成功触发event,得到flag。
接下来简要说明下 BalsnCTF 2019
pragma solidity ^0.4.24;
contract Bank {
event SendEther(address addr);
event SendFlag(address addr);
address public owner; // 0
uint randomNumber = RN; // 1
constructor() public {
owner = msg.sender;
}
struct SafeBox {
bool done; // 0_0_1
function(uint, bytes12) internal callback; // 0_1_9
bytes12 hash; // 0_9_21
uint value; // 1
}
SafeBox[] safeboxes; // 2
struct FailedAttempt {
uint idx; // 0
uint time; // 1
bytes12 triedPass; // 2_0_12
address origin; // 2_12_32
}
mapping(address => FailedAttempt[]) failedLogs; // 3
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);
}
}
可以发现这个结构体以及整体的布局结构和上面的是非常相似的。
只是function的结构体他只有两个storage占用,所以到时候调整的时候应该是除以2来调整以及计算。
致谢
- Ainevsia
- Pikachu
结语
感觉这里已经有一点pwn的意思了,后续想继续看看JOP相关的题目,感觉会很有意思分别是 Rw3rd的Re:Montagy以及qwb2020线下的EGM。因为这类题目的opcode 都需要精心的排布才能成功做成一个链。到时候也想去尝试下。