CounterStrike
1.question
源码:
1 | pragma solidity ^0.5.10; |
📌 目标:成功调用
Setup
合约中的isSolved()
函数。
2.analysis
调用
isSolved()
的前提是:将EasyBomb
合约的power_state
变量修改为false
,而能完成这个要求的只有setCountDownTimer
函数,delegatecall
嘛,调用逻辑合约的代码逻辑,其作用作用在自己身上。这为修改power_state
提供了可能性。而,调用该函数的前提是,通过两道修饰器,先来分析两个修饰器
isOwner():
1
2
3
4
5
6
7
8 modifier isOwner(){
require(msgPassword() == password);
require(msg.sender != tx.origin);
uint x;
assembly { x := extcodesize(caller) }
require(x == 0);
_;
}三个断言:
- 要猜对密码,密码的存储形式为:
bytes32 private password;
,在区块链中合约上的信息都是公开透明的,即使使用了private
修饰符,但是仍然可以通过脚本语言来获取,比如ethersjs
:
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 const { ethers } = require('hardhat');
describe("[chainflag]CounterStrike", function() {
let deployer, player;
// 执行操作的前序工作
before(async function() {
[deployer, player] = await ethers.getSigners();
});
// 攻击逻辑
it("Execution", async function() {
let contractAddress = ""; // EasyBomb'address
let slot = await ethers.provider.getStorage(contractAddress, 1);
console.log(`slot = ${slot}`);
});
// 验证是否通过
after(async function() {
});
});为何是获取
slot1
位置的值呢,因为bool private hasExplode = false; address private launcher_address;
这两个变量的存储空间加起来不不到32bytes
,EVM
或进行内存优化,将这两个值一同存放在slot0
的位置。
- 要求调用者不能是EOA,只需要通过一个中间合约调用即可
- 要求调用者中的代码量为
0
,简单,在构造函数constructor
中调用函数即可。notExplodeYet():
1
2
3
4
5
6 modifier notExplodeYet(){
launcher = Launcher(launcher_address);
require(block.number < launcher.deadline());
hasExplode = true;
_;
}
- 要求在一百个区块的时间内才可以调用
分析
setCountDownTimer(uint256)
1
2
3 function setCountDownTimer(uint256 _deadline) public isOwner notExplodeYet {
launcher_address.delegatecall(abi.encodeWithSignature("setdeadline(uint256)",_deadline));
}emmm,仔细分析还是蛮有意思的。怎么说呢,因为在
launcher_address
,其功能只是修改Launcher
合约中的deadline
,满打满算也只能将EasyBomb
合约中的slot0
位置的两个变量覆盖,不能修改到power_state
的值,索性修改launcher_address
为攻击合约的地址吧,这样setdeadline(uint256)
可以执行攻击逻辑。但是想象是美好的,中规中矩的覆盖会出现偏差,只填入地址值的话,实际存储到合约上
launcher_address
的值会低8位,如:
所以需要对地址进行左移8位:
<<8
。再看看
msgPassword()
函数中的如下代码
1
2
3
4
5 bytes memory msg_data = msg.data;
assembly {
result := mload(add(msg_data, add(0x20, 0x24)))
}
bytes memory msg_data = msg.data;
中msg_data
是动态数组类型,且加载到内存中,由于动态数组比较特殊,往往在msg_data
真正的数据值前先占用32bytes
来保存数组的长度,画个图:
而在内联汇编中直接写入变量名的作用是,直接到存储该变量的位置。
result := mload(add(msg_data, add(0x20, 0x24)))
:表示,先到存储msg_data
的位置,然后,跳过0x44个字节,为什么是 68bytes呢,因为前 32bytes 存储数组长度,3235bytes存储函数的构造器,3667bytes存储的是该函数选择器的形参,跳过这些之后,再读取32bytes
的数据,所料不错的话应该就是自己包装的password
。蛮细节的。
3. solve
攻击合约:
1 | contract Helper { |
SafeDelegatecall
1.question
源码:
1 | pragma solidity ^0.4.23; |
📌 成功调用
payforflag()
,也指成功执行完这个函数对吧。
2.analysis
题目要求是成功调用
payforflag()
函数,看一遍代码没发现可以成为owner
的漏洞,让我这菜鸟一度陷入迷茫,看了大佬的博客之后茅塞顿开。成功调用某个函数,无非就是将其函数体中的代码逻辑跑通,至于那些限制条件,无非就是阻止你成功运行函数体的代码而已,要是能直接跳过限制条件,问题就迎刃而解了。解题的关键在于
execute() 和 getRet()
函数
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 function execute(address _target) public payable{
require(_target.delegatecall(abi.encodeWithSelector(this.execute.selector)) == false, 'unsafe execution');
bytes4 sel;
uint val;
(sel, val) = getRet();
require(sel == SET);
Func memory func; // 这里声明为memory不会出现插槽覆盖
func.f = gift;
assembly {
mstore(func, sub(mload(func), val)) // 存放在memory中
}
func.f();
}
function getRet() internal pure returns (bytes4 sel, uint val) {
assembly {
if iszero(eq(returndatasize, 0x24)) { revert(0, 0) } // eq 相等返回 1,不相等返回0,要求返回值得是 36bytes
let ptr := mload(0x40) // 存储在 96 - 128 (32 bytes)
returndatacopy(ptr, 0, 0x24) // 将返回值的前36个字节拷贝到 memory中,起始位置为 0x40
sel := and(mload(ptr), 0xffffffff00000000000000000000000000000000000000000000000000000000) // 读取指针后32bytes的值,但是只保留前4bytes
val := mload(add(0x04, ptr))
}
}execute要求代理调用execute失败,且返回值的长度为 4 + 32 bytes,调用失败且自定义返回值的长度及其内容可以做到,但是这仍然无法成为
owner
,但是代码中有个漏洞
1
2
3
4 assembly {
mstore(func, sub(mload(func), val))
}
func.f();
func.f();
执行该函数,其实就是跳转到sub(mload(func), val)
,这怎么理解呢。我的理解是,函数在底层被编译为操作码的时候,代码将会被拆分存放,一个位置存放一段代码,而在某指定空间内,当调用某函数时,会读取内存中的值,并跳转到指定位置,而当调用
func.f()
的时候,其要跳转到的位置就是sub(mload(func), val)
这便要反编译合约证实。又知道,val的值是可以自定义的,所以进行函数调用的时候,函数可以跳转到任意位置,这将取决于 val的值。
反编译合约:
编译链接:website
如上图这是要跳转的位置:03c1
如上是payforflag()的操作码
1
2
3
4
5 function payforflag() public payable onlyOwner {
require(msg.value == 1, 'I only need a little money!');
emit SendFlag(msg.sender);
selfdestruct(msg.sender);
}
对应着:
1
2
3
4
5
6 Func memory func;
func.f = gift;
assembly {
mstore(func, sub(mload(func), val))
}
func.f();此时已经知道了,被减数(0x048a)和差(0x03c1),要求减数(to_sub); to_sub = 0x048a - 0x03c1 = 1162 - 961 = 201 = 0xc9,所以让其返回值,val=0xc9皆可完成挑战。
又因为,
_target.delegatecall(abi.encodeWithSelector(this.execute.selector))
进行函数代理调用的时候并没有传参,所以函数肯定是会调用失败的,而且甚至连函数体都进不去,更不用说设置返回值了,所以便要借助回调函数fallback
。
3. solve
攻击合约
1 | contract Hacker { |
总结
📌 知道了如何搭建calldata,在函数体内的
msg.data
,其实是通过call来调用函数时,才会有。而且,在十六进制表示的数中,两位数实则代表着8位。最重要的是,在函数初始化时,构造器中可以调本合约中的函数,但是在构造中,其他合约不能调用本合约的函数,什么意思呢,就是比如在构造器中调用本合约的函数中,该函数调用了其他合约的函数方法,同时其他合约被调用的函数需要调用调用者的某个函数,即使合约本身实现了该函数,但是由于在构造ing,所以函数将会调用失败。想要变高手那必然需要是需要去接触底层的代码逻辑,甚至是EVM操作码。懂得了函数在底层并不是一个函数在同一个地方罗列出来,而是通过一步步跳转实现的,调用函数时,可以提前改变跳转的位置,从而实现控制代码的走向,忽视一些限制条件。(二刷:
mstore(func, sub(mload(func), val))
,我觉得如果函数是这样修改的话,执行func时,底层逻辑为:执行到前四个字节,随后往下读取32bytes,这32bytes存储的是—>函数体内容,它可以是一个跳转地址,就比如sub(mload(func), val)
存储的就是func函数的函数体内存位置,所以只要改变跳转位置,就可以实现自由控制函数执行逻辑)