timezone |
---|
Asia/Shanghai |
-
自我介绍 我是一名后端开发工程师,负责公司云靶场的开发工作,熟悉OpenStack与Kubernetes等云计算技术
-
你认为你会完成本次残酷学习吗? 一定可以
- Ethernaut
根据提示在浏览器控制台中输入相应的命令即可 需要注意的是:
通过contract.abi
查看所有可用的方法,再通过contract.password()
方法获取密码
查看合约代码后发现receive方法中满足贡献值>0与发送交易的value>0时会将owner设置为sender
首先用小于0.001eth向合约捐献,调用contribute()函数,使我们拥有贡献值
await contract.contribute.sendTransaction({ from: player, value: toWei('0.0009')})
向合约发送一些eth,触发receive,获取owner
await sendTransaction({from: player, to: contract.address, value: toWei('0.000001')})
调用withdraw提取余额
await contract.owner()
这个合约中构造函数拼写错误导致任何人都可以调用Fal1out函数来获取owner权限
await contract.Fal1out()
这个挑战的核心点在于eth上的随机数是伪随机数,可直接按照合约中的算法写一遍来获取猜硬币结果
为了保证计算合约和题目的合约是在同一个区块中,因此需要写一个攻击合约来完成此次攻击
被攻击的合约地址需要写成ethernaut生成的合约地址
使用remix部署合约,写入ethernaut合约地址作为target
调用10次flip函数,即可过关
在Writeup/awmpy
目录下执行forge init
初始化forge项目
将CoinFlip的代码复制到coin_flip.sol
在Writeup/awmpy
目录下新建.env
文件,在文件中写入PRIVATE_KEY
环境变量,此变量会在脚本文件中被调用
编写脚本coin_flip_hack.s.sol,计算guess并调用ethernaut生成的合约,脚本中直接写死合约地址
执行命令进行调用10次后,即可过关
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/coin_flip_hack.s.sol:CoinFlipHackScript -vvvv --broadcast
这个挑战的核心点在于考察msg.sender
和tx.origin
的知识,msg.sender
可能是EOA或合约,tx.origin
只能是EOA
因此只需要实现以下调用链即可:
EOA ==> AttackContract ==> TelephoneContract
编写攻击合约telephone_hack.sol 部署攻击合约,部署时指定合约地址为ethernaut生成的合约地址
forge create --constructor-args "0xFce4169EcEa2f8FA0A12B0312C96Beb8d8734E76" --rpc-url https://1rpc.io/holesky --private-key $PRIVATE_KEY src/ethernaut/telephone_hack.sol:TelephoneHack
编写执行脚本telephone_hack.s.sol,其中攻击合约地址为刚部署的攻击合约地址 执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/telephone_hack.s.sol:TelephoneHackScript -vvvv --broadcast
这个挑战是考察溢出漏洞,Token合约使用的版本是0.6.0,且没有使用SafeMath
此题目给玩家预分配了20枚代币,因此只需要调用合约的transfer
方法向任意地址转移21
枚代币就可以触发漏洞
编写攻击脚本token_hack.s.sol,其中实例化Token合约使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/token_hack.s.sol:TokenHackScript -vvvv --broadcast
这个挑战是考察Delegatecall相关知识
Delegation
合约中实现了一个fallback
函数,在调用此合约中不存在的函数时,fallback
函数就会被调用,将原来的calldata传递给它
Delegate
合约中实现了一个pwn
方法,将owner
改为msg.sender
而在Delegatecall
时,msg.sender
和msg.value
都不会改变,只需要写脚本调用Delegation
合约的pwn
方法即可获得Delegate
合约的owner
权限
调用pwn
方法时需要使用abi.encodeWithSignature
将函数名转为function signature
进行调用
编写攻击脚本delegation_hack.s.sol,其中实例化Token合约使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/delegation_hack.s.sol:DelegationHackScript -vvvv --broadcast
考察selfdestruct知识,编写一个合约,自毁时强制把一些eth转给目标合约地址即可
编写攻击脚本force_hack.s.sol,其中转账地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/force_hack.s.sol:ForceHackScript -vvvv --broadcast
这是一个猜密码的游戏,需要用到数据存储相关的知识
每个存储槽将使用32个字节(一个字大小) 对于每个变量来说,会根据其类型确定以字节为单位的大小 如果可能的话,少于32字节的多个连续字段将根据以下规则被装入一个存储槽 一个存储槽中的第一个项目以低位对齐的方式存储 值类型只使用存储它们所需的字节数 如果一个值类型在一个存储槽的剩余部分放不下,它将被存储在下一个存储槽 结构和数组数据总是从一个新的存储槽开始,它们的项目根据这些规则被紧密地打包 结构或数组数据后面的项目总是开始一个新的存储槽
locked
变量存储在slot0,password
变量因为是32字节类型,无法存放到slot0,只能是在slot1中
在foundry中使用vm.load
来获取password
变量内容
编写攻击脚本vault_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/vault_hack.s.sol:VaultHackScript -vvvv --broadcast
这是一个关于DOS攻击的游戏
区块链上较为常见的攻击方式有消耗过高的GAS
、External call导致合约不受控
这个挑战就是要通过External call
的方式,让其他人无法获得王位
King
合约中实现了一个receive
函数,会在转账给这个合约时触发,但转账金额要大于当前King的prize,通过校验后就会将Ether转给当前King,再把msg.sender
设置为新的king
而这里又没有规定King是EOA还是合约
因此有了以下攻击思路:
- 编写一个合约,给
King
合约转账触发King
合约的receive
函数来使攻击合约成为King - 攻击合约中实现一个估计将交易revert掉的
receive
方法让其他人无法再向King转账,以次实现DOS的目的
编写攻击合约king_hack.sol 编写攻击脚本king_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/king_hack.s.sol:KingHackScript -vvvv --broadcast
Re-entrancy
是一种常见的攻击手法,利用合约external call外部合约时,外部合约故意回call原始合约,让原始合约再次执行external call,直到达成攻击者目的或GAS耗尽,由于攻击者会二次或多次进入目标合约,故被称为重入攻击
在Re-entrancy
合约的withdraw
函数中:
- 先检查提款者的余额是否足够
- 将
_amount
转入提款者账户 - 最后修改提款者的余额
攻击手法:
- 攻击合约调用
donate
函数,存入一些Ether - 攻击合约调用
withdraw
函数,提取存入的Ether,让external call触发攻击合约的receive
函数 - 攻击合约的
receive
函数再次调用目标合约的withdraw
函数 - 重复2-3直到目标合约中所有的Ether都被转走,而修改提款者余额这一步永远不会执行
编写攻击合约reentrance_hack.sol 编写攻击脚本reentrance_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/reentrance_hack.s.sol:ReentranceHackScript -vvvv --broadcast
这一题比较简单,攻击合约中实现一个isLastFloor
方法,并且第一次被调用时return false,第二次被调用时return true就能将top设置为true
编写攻击合约elevator_hack.sol 编写攻击脚本elevator_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/elevator_hack.s.sol:ElevatorHackScript -vvvv --broadcast
这一提与Valut十分相似,都是需要通过获取变量的值来通关,核心点还是变量存储的问题,找到密码所在的slot
经过以下推算可以得知data[2]
存储在slot5
中
var | bytes | slot |
---|---|---|
bool public locked | 1 | 0 |
uint256 public ID | 32 | 1 |
uint8 private flattening | 1 | 2 |
uint8 private denomination | 1 | 2 |
uint16 private awkwardness | 2 | 2 |
bytes32[3] private data[0] | 32 | 3 |
bytes32[3] private data[1] | 32 | 4 |
bytes32[3] private data[2] | 32 | 5 |
读取slot5值即可通关
编写攻击脚本privacy_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/privacy_hack.s.sol:PrivacyHackScript -vvvv --broadcast
这一题有3道门,需要三道门全部通过才可以通关
第一道门要求msg.sender != tx.origin
,只需要使用合约而不是EOA调用目标合约即可
第二道门要求gasleft能被8191整除,这个需要在合约中爆破一下,多调用几次目标
第三道门的条件比较多,需要将tx.origin
转成bytes8bytes8(uint64(uint160(tx.origin)))
,再使用AND运算将从右往左数的3-4bytes修改掉,即可通过第三关bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF
编写攻击合约gatekeeper_one_hack.sol 编写攻击脚本gatekeeper_one_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/gatekeeper_one_hack.s.sol:GatekeeperOneHackScript -vvvv --broadcast
这一道题同样有三道门
第一道门还是要求msg.sender != tx.origin
,只需要使用合约而不是EOA调用目标合约即可
第二道门中有extcodesize
和assembly
,extcodesize
是一种opcode,用来取指定地址的合约大小,单位为bytes,第二道门的检查需要确保extcodesize(calller())
的值等于0
第一道门要求caller必须是合约,第二道门又要求caller的extcodesize
必须是0,唯一能满足条件的只有不包含runtime code的合约
runtime code是最终留在区块链上执行的代码,可以被不断重复调用、执行,creation code是执行一次初始化合约的状态后就消失,因此我们只需要把攻击代码写到construst
中,不写其他的func,即可通过第二道门
第三道门中有一个^
,代表bitwise的XOR异或运算,需要将uint64(_gatekey)
与type(uint64).max
位置交换,就能取得正确的key,需要将原本的msg.sender()
改成address(this)
也就是攻击合约
uint64 key = uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ type(uint64).max);
编写攻击合约gatekeeper_two_hack.sol
编写攻击脚本gatekeeper_two_hack.s.sol,其中合约地址使用ethernaut提供的合约地址,脚本中只需要new攻击合约即可,因为攻击合约只实现了construst
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/gatekeeper_two_hack.s.sol:GatekeeperTwoHackScript -vvvv --broadcast
这一道题需要把自己的余额清空
lockTokens
中限制了msg.sender
不能是player,因此需要通过攻击合约来发送转账请求
攻击思路:
player将所有代币授权给攻击合约,攻击合约调用transferFrom
函数把player的代币清空,from写player地址,to写攻击合约本身或其他地址
编写攻击合约naught_coin_hack.sol 编写攻击脚本naught_coin_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/naught_coin_hack.s.sol:NaughtCoinHackScript -vvvv --broadcast
这一题有两个合约Preservation
与LibraryContract
,两个合约之间使用了delegatecall
Preservation
中有4个storage变量,timeZone1Library位于slot0,timeZone2Library位于slot1,owner位于slot2,storedTime位于slot3
LibraryContract
中只有1个storage变量,storedTime位于slot0
当调用Preservation
的setFirstTime
函数时,会delegatecall
到LibraryContract
的setTime
方法来修改storedTime变量
漏洞就出现在两个合约的slot0所存储的变量不同,Preservation
在进行delegatecall
到LibraryContract
的setTime
时修改的是本地的slot0,也就是timeZone1Library
这样就可以用攻击合约调用setFirstTime
函数来将timeZone1Library改成攻击合约
改成攻击合约后再次调用setFirstTime
函数,就会调用到攻击合约的setTime
,可以在攻击合约的setTime
中修改owner,因为是delegatecall
,此处修改的也是Preservation
的owner,以此获取合约控制权
第一次调用setFirstTime
调用链
EOA ==> AttackContract ==> Preservation ==> LibraryContract ==> setTime ==> 篡改Preservation的timeZone1Library为AttackContract
第二次调用setFirstTime
调用链
EOA ==> AttackContract ==> Preservation ==> AttackContract ==> settime ==> 篡改Preservation的owner为player
编写攻击合约preservation_hack.sol 编写攻击脚本preservation_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/preservation_hack.s.sol:PreservationHackScript -vvvv --broadcast
这一题需要找出新创建的SimpleToken
合约地址,可通过etherscan或自己算
然后调用合约的destroy
方法即可
使用keccack256(RLP_encode(address, nonce))
可计算出合约地址,是由'creator address'及其nonce经过RLP编码后,在经过keccack256算法取最右边160bits
address:是合约创建者的地址,也就是Recovery
的地址
nonce:是合约发送的总交易数量,如果是EOA会从0开始计算,而合约是从1开始计算,假设Recovery
是新创建的合约,那么nonce值就是1
通过以下算法计算新合约地址
address newAddress = address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xd6),
bytes1(0x94),
challengeInstance,
bytes1(0x01)
)))));
编写攻击合约recovery_hack.sol 编写攻击脚本recovery_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/recovery_hack.s.sol:RecoveryHackScript -vvvv --broadcast
此次目标是部署一个小于10 opcode的合约 如果按照往常写solidity合约后部署,即使只有1个函数,也会超过10opcode,因此我们要想办法写出一个最轻量的bytescode合约
bytescode会分成creation code和runtime code,由于检查是否通关是通过EXTCODESIZE,所以我们的runtime code不能大于10个opcode。接下来分成creation code和runtime code两部分来分析
solver需要42作为返回值,42对应的十六进制数字是0x2a return对应的opcode是RETURN,但RETURN(p, s)需要两个参数,p是返回值在内存中的位置,s是返回值的大小。这意味着0x2a需要先存到内存中才能被返回,因此还需要第二个opcode MSTORE(p, v)。MSTORE的参数中p是存储值在内存中的位置,v是存储值。而为了得到RETURN和MSTORE这两个opcode所需要的参数,还需要利用PUSH1这个opcode来把参数推入stack,所以Runtime Code会使用到的opcode共有3个
OPCODE | NAME |
---|---|
0x60 | PUSH1 |
0x52 | MSTORE |
0xf3 | RETURN |
接着就按照顺序开始:
- 先用MSTORE将42(0x2a)存储到内存中
EVM在执行opcode时,基本上参数都是从stack最上方pop出的值,由于stack的特性是后进先出,所以在执行MSTORE(p, v)时,需要先被PUSH1进入stack的参数是v,也就是0x2a 然后要被PUSH1的参数才是p,因为没有要求要放在内存的哪个位置,所以可以随意挑选,但通常0x80之前的位置都有其他用途,比如0x40就是free memory pointer,所以default从0x80开始存储,就选择0x80用来存储p
OPCODE | DETAIL |
---|---|
602a | push 0x2a in stack. Value(v) param to MSTORE(0x60) |
6080 | push 0x80 in stack. Position(p) param to MSTORE |
52 | store value,v=0x2a in position p=0x80 in memory |
- 再用RETURN将42(0x2a)返回
由于前面用MSTORE将42写入了memory,现在就可以使用RETURN将42返回 RETURN(p, s)也需要两个参数,需要先被PUSH1进入stack的是s(返回值的大小),因为返回值42是uint256,大小为32bytes,也就是0x20 然后需要被PUSH1进入stack的值是p(返回值在memory中的位置),也就是0x80
OPCOE | DETAIL |
---|---|
6020 | push 0x20 in stack. Size(s) param to RETURN(0xf3) |
6080 | push 0x80 in stack. Postion(p) param to RETURN |
f3 | RETURN value=0x2a, size=0x20, position=0x80 |
最后将上述两个步骤的opcode合并到一起就是:602a60805260206080f3,刚好组成了10个bytes大小的Runtime Code
接下来要组存Creation Code来讲Runtime Code部署到链上。Creation Code实际的操作是先把Runtime Code加载到memory中,再将其返回给EVM,随后EVM会把602a60805260206080f3这串bytescode存储到链上,而这部分不需要我们处理
将Runtime Code代码加载到memory中的opcode是CODECOPY(d,p,s),需要3个参数,d代表memory中复制代码的目标位置,p代表Runtime Code的当前位置,s则代表以byte为单位的代码大小。而返回给EVM同样是使用RETURN(p,s),因为这两个opcode都有参数所以同样也需要用PUSH1把参数推入到stack中。
因此Creation Code会用到3个opcode
OPCODE | NAME |
---|---|
0x60 | PUSH1 |
0xf3 | RETURN |
0x39 | CODECOPY |
接着就按照顺序开始:
- 先用CODECOPY将Runtime Code复制到memory中
同样基于EVM Stack的后进先出原则,所以在执行CODECOPY(d, p, s)时需要先PUSH1进入stack的值是s(代码大小,以bytes为单位),也就是Runtime Code的大小10bytes,所以s值等于0x0a
第二个要被PUSH1的参数是p,也就是Runtime Code的位置。由于Creation Code还未完成,无法确定Runtime Code的真正位置,先留空
第三个要被PUSH1的参数是d,也就是memory中复制代码的目标位置,直接选用0x00这个位置即可,因为当EVM执行到COPYCODE代表已经到了程序执行尾端,所以这时编译器已不需要之前提到的0x40 free memory pointer了
OPCODE | DETAIL |
---|---|
600a | push 0x0a in stack. size of runtime code 10 bytes |
60?? | push ??(un) unknown in stack. Position(p) param to COPYCODE |
6000 | push 0x00 in stack. Destination(d) param to COPYCODE |
39 | COPYCODE |
- 再用RETURN将Runtime Code返回给EVM
由于前面通过COPYCODE把Runtime Code写入到了memory中,接下来使用RETURN把Runtime Code返回给EVM
RETURN(p, s)需要两个参数,需要先被PUSH1进入stack的是s(返回值的大小),因为返回值大小就是Runtime Code的大小10bytes,因此s的值是0x0a 然后需要被PUSH1进入stack的值是p(返回值在memory中的位置),也就是0x00
OPCOE | DETAIL |
---|---|
600a | push 0x0a in stack. Size(s) param to RETURN(0xf3) |
6000 | push 0x00 in stack. Postion(p) param to RETURN |
f3 | RETURN size=0x0a, position=0x00 |
再将上述两个步骤的opcode组合起来就是:600a60??600039600a6000f3,组成12个bytes大小的Creation Code,知道了Creation Code的大小就可以把??给填上,也就是0x0c,因此最终的opcode就是:600a600c600039600a6000f3
再把Creation Code与Runtime Code拼接在一起就是
600a600c600039600a6000f3(Creation Code) + 602a60805260206080f3(Runtime Code) = 600a600c600039600a6000f3602a60805260206080f3
这就是我们要部署的合约
编写攻击合约magic_number_hack.sol 编写攻击脚本magic_number_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/magic_number_hack.s.sol:MagicNumberHackScript -vvvv --broadcast
合约中继承的Ownable../helpers/Ownable-05.sol
Ownable合约中第一个变量address private _owner;
,想办法改掉这个值就可以获得合约控制权,被继承的合约中storage variable会存储在原合约之前,所以_owner
是存储在slot 0这个位置
合约是用了0.5.0版本,小于0.8.0,可能存在overflow/underflow漏洞,要想修改slot 0就需要利用这个漏洞(之前的挑战中有使用vm.load读取过slot 0的数值破解key,还有利用漏洞替换掉slot 0存储的合约),本次需要利用合约中的array溢出来达到修改slot 0的目的
合约中record
、retract
和revise
都有contacted这个modifier,其中对contact值做了判断,因此需要先调用makeContact
来将contact值改为true
假设有个未知长度的arrayuint256[] c
,变量c所在的位置存储的值是c.length
,而其中的元素会从keccak256(slot)
开始,假设c存储在slot 2,也就是说其中元素c[0]是存储在keccak256(2)
,c[1]存储在keccak256(2) + 1
以此类推
Solidity版本小于0.8.0意味着没有溢出检查,可以通过调用retract()
使用当前长度为0的codex减去1,它的长度会因为0-1发生下溢而变成一个很大的值(2256-1)
有了这么长的codex之后,它的index能够覆盖所有的slot(2256-1),也就是说此时codex的长度与slot的总数相同都是(2**256-1)
,我们就可以通过调用revise来修改codex中的任意值,也就可以修改任意slot的值
但又因为codex的元素存储是从keccak256(2)
开始,因此需要算出正确的slot 0在codex的中index
Slot | Data |
---|---|
0 | owner address |
1 | codex.length |
... | ... |
p+0 | codex[p+0 - p] |
p+1 | codex[p+1 - p] |
... | ... |
2^256-2 | codex[2^256-2 - p] |
2^256-1 | codex[2^256-1 - p] |
0 | codex[2^256-0 - p] |
假设codex[0]位于slot p,那么slot 0就对应的index就是2^256-p
,因为codex存储在slot 0,p值就是keccak256(1)
,slot 0对应的index就是2^256 - keccak256(1)
有了正确的index,再把msg.sender写入这个这个index就可以获取合约所有权
攻击步骤:
- 调用makeContact把contact值改为true
- 调用retract把codex长度溢出
- 计算slot 0在codex中的index,调用revise写入msg.sender
编写攻击合约alien_codex_hack.sol 编写攻击脚本alien_codex_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/alien_codex_hack.s.sol:AlienCodexHackScript -vvvv --broadcast
这一关比较简单,用到了之前用到过的DOS攻击和Re-entrancy攻击,最终目标是让owner在调用withdraw的时候无法正常提款
先调用setWithdrawPartner
成为partner,再通过在攻击合约实现receive的方式循环调用withdraw,让owner无法获得分成即可
编写攻击合约denial_hack.sol 编写攻击脚本denial_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/denial_hack.s.sol:DenialHackScript -vvvv --broadcast
这一关类似于Elevator,只不过额外做了view限制,无法直接修改状态,但可以利用攻击合约中的函数根据isSold状态判断来返回不同的值
当isSold为True,则返回1,isSold为False则返回100 这样就可以用100通过第一个判断,用1实现购买
编写攻击合约shop_hack.sol 编写攻击脚本shop_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/shop_hack.s.sol:ShopHackScript -vvvv --broadcast
这一关的漏洞出现在getSwapPrice()
函数中,由于其中除法会出现向下取整的问题
amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
只需要不停的swap手动的全部代币,就可以掏空池子
STEP | DEX token1 | DEX token2 | Player token1 | Player token2 |
---|---|---|---|---|
Init | 100 | 100 | 10 | 10 |
Swap 1 | 110 | 90 | 0 | 20 |
Swap 2 | 86 | 110 | 24 | 0 |
Swap 3 | 110 | 80 | 0 | 30 |
Swap 4 | 69 | 110 | 41 | 0 |
Swap 5 | 110 | 45 | 0 | 65 |
Swap 6 | 0 | 90 | 110 | 20 |
在执行第6次swap时,池子中只剩45个Token2,所以我们只需要换45个就可以
编写攻击合约dex_hack.sol 编写攻击脚本dex_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/dex_hack.s.sol:DexHackScript -vvvv --broadcast
这一关与上一关的合约几乎一模一样,只不过去除了只能token1和token2互换的限制,并且要求清空token1和token2 只需要自己发行一个ERC20的代币Evil,然后去换token1和token2即可
Step | DEX token1 | DEX token2 | DEX WETH | Player token1 | Player token2 | Player WETH |
---|---|---|---|---|---|---|
Init | 100 | 100 | 100 | 10 | 10 | 300 |
Swap 1 | 0 | 100 | 200 | 110 | 10 | 200 |
Swap 2 | 0 | 0 | 400 | 110 | 110 | 0 |
编写攻击合约dextwo_hack.sol 编写攻击脚本dextwo_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/dextwo_hack.s.sol:DexTwoHackScript -vvvv --broadcast
这个挑战是一个代理合约,通过PuzzleProxy
代理对PuzzleWallet
的请求,通过delegatecall
转发请求,通关目标是获取PuzzleProxy
合约的owner权限
通过delegatecall
转发的请求,对被调用的合约所做的修改都会保存在Proxy合约中
在实现可升级合约时,如果没能保证slot排列相同,就会发生更新一个合约存储变量时错误的更新了另一个合约对应slot的变量
slot排列:
Slot | PuzzleProxy | PuzzleWallet |
---|---|---|
0 | pendingAdmin | owner |
1 | admin | maxBalance |
因为我们需要变成PuzzleProxy
的管理员,所以需要想办法把slot1改成我们的钱包地址,slot1上有maxBalance
和admin
两个存储变量,通过修改maxBalance
就可以覆盖掉admin
,这就是我们的最终目标了
可以修改maxBalance
变量值只有两个地方init
函数和setMaxBalance
函数,init
函数中要求maxBalance
为0才能执行,但init
已被执行过,这个值不是0,只能看setMaxBalance
函数能否利用
setMaxBalance
函数要求调用者在白名单中,且合约的balance为0
目前的目标就变成了:
- 让自己加入白名单
addToWhitelist
函数要求是owner才能将地址加入白名单 而我们通过错误的slot排列可以发现,更新PuzzleProxy
的pendingAdmin
值就可以把PuzzleWallet
的owner
改掉,这样就能顺利拿到PuzzleWallet
的owner权限,并把自己的地址加入到白名单中 - 清空合约余额
execute
是唯一一个可以向其他地址进行call()
并带有一些value的函数,我们可以利用这个函数把合约内所有的钱转走,但它要检查msg.sender
是否有足够的余额来操作,必须要想办法把balances[msg.sender]
加到大于或等于合约余额,才能把合约内所有钱转走deposit
函数可以增加msg.sender
的余额,调用deposit
会发生两件事:balances[msg.sender]
增加和合约的balance增加,合约内部署时已经有了0.001ether 假设调用deposit
存入0.001ether,会发生balances[msg.sender]
变为0.001,合约的balance变为0.002,还是无法满足balances[msg.sender]
大于等于合约balance,因此需要想办法让balances[msg.sender]
增加两次0.001,而合约balance只增加一次0.001,这样就可以让balances[msg.sender]
有合约balance都是0.002,这样才能调用execute
把合约balance清空
目前的目标就变成了:调用deposit
让balances[msg.sender]
增加两次0.001,而合约balance只增加0.001
要实现这个目标需要借助PuzzleWallet
中的multicall
函数,它允许用户在单笔交易中多次调用一个函数,来实现节省gas的目的
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add( , 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
如果能够在同一笔交易中使用0.001个以太币调用deposit
两次,这意味着只提供了一次0.001个以太币,玩家的余额 balances[msg.sender]
将从0变为0.002,但实际上,由于我们在同一笔交易中这样做了,我们的存款金额仍将为0.001
但在multicall
函数中利用depositCalled
变量限制了deposit
只能被调用一次
multicall
可以调用PuzzleWallet
的任意函数,包括multilcall
本身
这样就给我们提供了机会,可以在一个multicall
的调用中再嵌套multicall
,而每个multicall
的depositCalled
都是单独计算的,这样就能实现在一个交易中调用两次deposit
逻辑示意:
multicall = [
multicall: [deposit],
multicall: [deposit]
]
OR
multicall = [
multicall: [deposit],
deposit
]
到目前为止,完整的攻击路径已经出现:
- 调用
PuzzleProxy
的proposeNewAdmin
函数传入player地址,达到修改PuzzleWallet
的owner的目的,获取PuzzleWallet
的owner权限 - 调用
PuzzleWallet
的addToWhitelist
,传入player地址,把自己加入到白名单中 - 调用
PuzzleWallet
的multicall
将组装好的data和0.001ether发送过去,达到balances[msg.sender]
等于合约balance的目的 - 调用
PuzzleWallet
的execute
将合约内的0.002ether转到player地址 - 调用
PuzzleWallet
的setMaxBalance
将maxBalance
的值改为player地址,同时因为maxBalance
和PuzzleProxy
的admin
变量都在slot1存储,此时也获取了PuzzleProxy
合约的admin权限
将合约代码复制到puzzle_wallet.sol 编写攻击脚本puzzle_wallet_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/puzzle_wallet_hack.s.sol:PuzzleWalletHackScript -vvvv --broadcast
本关的目标是在合约Engine
上调用selfdestruct
让代理合约无法再使用
在Dencun升级之后,在EIP-6780中更改了SELFDESTRUCT
操作码的功能,新功能只是将账户中的所有以太币发送到目标,但在创建合约的同一交易中调用SELFDESTRUCT
时,当前行为将被保留
因此要将创建instance合约和实现selfdestruct
在同一交易中实现才行
背景知识:
这一关中使用了UUPS(Universal Upgradeable Proxy Standard)
的代理模式,上一个关中使用的是TPP(Transparent Proxy Pattern)
的代理模式
UUPS与TPP相比主要有几个区别:
- UUPS的升级函数是实现在
Logic Contract
中,而不是Proxy Contract
中 - UUPS不会在每次call的时候都去检查调用者身份,而只在升级时检查
代理合约中最容易出问题的两个点就是:Storage collision
和initialization
上一关中就利用了两次Storage collision
漏洞来获取admin权限,为了避免Storage collision
漏洞发生,ERC-1967规定了重要变量的storage slot位置,例如Logic、Admin、Beacon等,通过对字符串eip1967.proxy.xxx
使用keccak256
加密后,把得到的结果当做slot index,将xxx变量的值放到这个很远很大的slot中,以此大幅度降低碰撞的风险
初始化initialization
是指代理合约的初始化,一般在部署合约时都会通过constructor
设置一些初始化变量,但代理合约的情况下,如果在Logic Contract
中使用constructor
来初始化,变量会保存在Logic Contract
中,就不符合变量都保存在Proxy Contract
中的设计,因此会在Logic Contract
中定义一个initialize
函数来做一些初始化的工作,并且使用一个initialized
来确保initialize
只被调用一次
在一关中Proxy Contract
是Motorbike,Logic Contract
是Engine,并且使用了ERC-1967来防止Storage collision
目标是调用Engine的selfdestruct
函数让其自毁,但在Engine中没有实现这个函数,就需要考虑升级Logic Contract
让其变成攻击合约,在攻击合约中实现一个selfdestruct
Engine实现了一个upgradeToAndCall
函数来升级合约,但是限制了msg.sender == upgrader
目标就变成了让自己成为upgrader
,Engine中只有initialize
函数可以设置upgrader
,因为这一关使用了ERC-1967,Proxy Contract
与Logic Contract
没有相似的slot排布,无法像上一关中直接修改upgrader
的值,只能通过initialize
来更新upgrader
initialize
函数有一个initializer
装饰器来进行对initializer
的校验,确保合约只被初始化一次,但由于Proxy Contract
调用initialize
是通过delegatecall
,会导致initializer
是存储在Proxy Contract
中,Logic Contract
中的initializer
仍是未初始化状态,就可以绕过Proxy Contract
直接调用initialize
,这样就可以让msg.sender
成为upgrader
综上所述,攻击思路如下:
- 创建一个实现了调用
selfdestruct
函数的攻击合约 - 读取
_IMPLEMENTATION_SLOT
,并计算出Engine合约的地址 - 调用Engine的
initialize
函数来成为upgrade
- 调用Engine的
upgradeToAndCall
来将Logic Contract
升级为攻击合约,并调用升级后共计合约进行自杀
编写攻击合约motorbike_hack.sol 编写攻击脚本motorbike_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/motorbike_hack.s.sol:MotorbikeHackScript -vvvv --broadcast
这一关提供了两个ERC20的合约LegacyToken(LGT)
和DoubleEntryPoint(DET)
以及一个金库合约CryptoVault
,金库内存有LGT和DET各100个,但金库存在BUG可能会被人把金库中的代币转走,我们需要想办法保护合约内的代币
LGT合约:
在LGT合约中重写了ERC20默认的transfer
函数,检查delegate
地址为0的情况,就从ERC20中调用transfer
函数,如果delegate
地址不为0,就调用delegate.delegateTransfer
函数
LGT中还实现了delegateToNewContract
函数,用来设置delegate
的值,且这个函数有onlyOwner
装饰器,只能由owner来设置delegate
,且newContract
需要是DelegateERC20
DET合约:
合约继承了DelegateERC20
,满足作为LGT的delegateToNewContract
参数的条件,因此LGT中的delegate
就是DET合约
构造函数中定义了delegatedFrom
、forta
、player
、cryptoVault
地址,并且给CryptoVault
mint了100个DET
装饰器onlyDelegateFrom
限制了msg.sender
只能是设置为LegacyToken
地址的delegatedFrom
合约,这意味着使用这个装饰器的函数只能被LegacyToken
来调用
装饰器fortaNotify
是Forta机器人使用的,他会先把forta.botRaisedAlerts(detectionBot)
执行的结果存储起来,调用一次forta.notify(player, msg.data)
来给机器人发送通知,接着正常执行函数功能,之后再调用一次forta.botRaisedAlerts(detectionBot)
,并将notify前后两次的结果进行比较,如果第二次数量大于第一次,就revert整个交易,这个装饰器只应用于delegateTransfer
函数
函数delegateTransfer
设置了fortaNotify
和onlyDelegateFrom
两个装饰器,说明这个函数只允许LegacyToken
来调用,并且可以由bot来检查交易是否合理,此函数会调用_transfer
函数,将value数量的代表从origSender
转到to
CryptoVault合约:
函数setUnderlying
设置了underlying
的代币地址,在这里是DET的地址,且只能调用一次,也就是说我们无法再修改
函数sweepToken
将ERC20的代币地址作为函数参数,并确保它不等于underlying
也就是DET,然后调用了新传入的代币的transfer
函数,将金库中所有的代币都转给sweptTokensRecipient
sweptTokensRecipient
地址是在构造函数中设置的,不受我们控制
攻击思路:
假设我们是攻击者,唯一能把CryptoVault
合约中代币转走的函数是sweepToken
,但由于限制了token不能是DET,只能考虑给这个函数传入LGT的地址这样就会调用LGT的transfer(sweptTokensRecipient, CryptoVault's Total Balance)
函数(重写过的)
最终会调用到delegate.delegateTransfer(to, value, msg.sender)
,也就是DoubleEntryPoint.delegateTransfer(sweptTokensRecipient, CryptoVault's Total Balance, CryptoVault's Address)
现在流程走到了DET的delegateTransfer
函数中,onlyDelegateFrom
的限制会被通过,因为msg.sender
是LGT,这样就能够绕过sweepToken
的限制,而把所有的DET给转走
具体操作:
// ethernaut提供的instance地址是DET的地址,可以通过`cryptoVault`变量查到`CryptoVault`的合约地址,通过`delegatedFrom`来获取LGT的地址
vault = await contract.cryptoVault()
// 检查DET余额 (100 DET)
await contract.balanceOf(vault).then(v => v.toString()) // '100000000000000000000'
// 查询LGT的Address
legacyToken = await contract.delegatedFrom()
// 组装通过sweepToken转走DET的Data
sweepSig = web3.eth.abi.encodeFunctionCall({
name: 'sweepToken',
type: 'function',
inputs: [{name: 'token', type: 'address'}]
}, [legacyToken])
// 发送攻击请求
await web3.eth.sendTransaction({ from: player, to: vault, data: sweepSig })
// 再次检查DET余额 (0 DET)
await contract.balanceOf(vault).then(v => v.toString()) // '0'
防御思路:
Forta合约:
函数setDetectionBot
用来设置机器人的地址,需要利用这个函数把机器人设置为自己的机器人地址
函数notify
中调用了机器人的handleTransaction
函数来检查calldata,因此我们需要在机器人中实现一个handleTransaction
函数并设置一些条件来触发告警,此函数会在DET合约中的fortaNotify
装饰器中被调用,也就是用来触发通知
函数raiseAlert
会将msg.sender
的告警数加1
还有个IDetectionBot
的接口,其中有handleTransaction
函数名标签
攻击路径:
CryptoVault.sweepToken(LGT) ==> LGT.transfer(sweptTokensRecipient, CryptoVault's Token Balance) ==> DET.delegateTransfer(sweptTokensRecipient, CryptoVault's Total Balance, CryptoVault's Address)
调用到delegateTransfer
函数时,装饰器fortaNotify
会把接受到的msg.data
发送给机器人的handleTransaction
来处理,因此需要实现一个带有handleTransaction
函数的机器人,并检查msg.data
中的origSender
是不是CryptoVault
的地址
解析calldata:
在fortaNotify
中msg.data
是function delegateTransfer(address to, uint256 value, address origSender)
在notify
中调用了handleTransaction
函数,这里会改变msg.data
到了handleTransaction
函数中,msg.data
就变成了function handleTransaction(address user, bytes calldata msgData) external
,其中的第二个参数bytes calldata msgData
才是我们想要的原本的msg.data
,需要从中提取出origSender
机器人看到的calldata数据排列:
Position | Bytes Length | Var Type | Value |
---|---|---|---|
0x00 | 4 | bytes4 | Function selector of handleTransaction(address,bytes) == 0x220ab6aa |
0x04 | 32 | address | user address |
0x24 | 32 | uint256 | msgData 的偏移量 |
0x44 | 32 | uint256 | msgData 的长度 |
0x64 | 4 | bytes4 | Function selector of delegateTransfer(address,uint256,address) == 0x9cd1a121 |
0x68 | 32 | address | to 参数地址 |
0x88 | 32 | uint256 | value 参数 |
0xA8 | 32 | address | origSender 参数地址 |
0xC8 | 28 | bytes | 根据编码字节的 32 字节参数规则进行零填充 |
可通过cast sig "handleTransaction(address,bytes)"
获取函数签名
从表中可以看出,前半部分是函数handleTransaction
,后半部分是delegateTransfer
,其中就有需要的origSender
数据
编写攻击合约double_entry_point_hack.sol 编写攻击脚本double_entry_point_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/double_entry_point_hack.s.sol:DoubleEntryPointHackScript -vvvv --broadcast
这一关的目标是转走钱包中所有的代币
攻击思路:
- 编写合约调用
GoodSamaritan
中的requestDonation
函数,触发wallet.donate10
wallet.donate10
调用coin.transfer
来给攻击合约转账10个代币coin.transfer
中判断转账目标是合约后,会调用攻击合约的notify
函数- 我们需要再攻击合约中实现一个
notify
函数,revertNotEnoughBalance
错误,用来触发requestDonation
中的wallet.transferRemainder(msg.sender)
逻辑 wallet.transferRemainder(msg.sender)
中会再次调用coin.transfer
来将钱包内所有代币转给攻击合约- 这次同样会触发攻击合约的
notify
,但这一次我们希望正常接收代币,不应该revertNotEnoughBalance
错误,所以需要在notify
中对_amount
值判断等于10(因为第一次转账10个)时才去revert error
编写攻击合约good_samaritan_hack.sol 编写攻击脚本good_samaritan_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/good_samaritan_hack.s.sol:GoodSamaritanHackScript -vvvv --broadcast
第一道门,GatekeeperThree
的初始化函数写错了,直接调用construct0r
函数就可以获得owner权限
第二道门,在攻击合约中调用createTrick
来创建SimpleTrick
,再调用getAllowance
时传入block.timestamp
即可,因为在同一笔交易中block.timestamp
相同,所以密码就是这个
第三道门,在攻击合约中向目标发送0.0011 ether,并且攻击合约不实现receive
函数
编写攻击合约gatekeeper_three_hack.sol 编写攻击脚本gatekeeper_three_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/gatekeeper_three_hack.s.sol:GatekeeperThreeHackScript -vvvv --broadcast
calldatacopy用于将calldata复制到内存中,函数第一个参数表示内存存储位置,第二个参数用于设置要复制的calldata的偏移量,最后一个参数定义要复制数据的大小,在这个合约中分别是(selector, 68, 4)
最后检查复制到selector数组中的第一个index的值是不是turnSwitchOff
函数的函数选择器
为了将switchOn变量设置为true,需要在calldata中传递turnSwitchOn
函数的函数选择器
组装calldata,内含3个函数选择器:
func flipSwitch(bytes memory _data)
- 0x30c13ade 命令cast sig "flipSwitch(bytes memory _data)"
func turnSwitchOff()
- 0x20606e15 命令cast sig "turnSwitchOff()"
func turnSwitchOn()
- 0x76227e12 命令cast sig "turnSwitchOn()"
onlyOff要求calldata的偏移量为64bytes,值为0x20606e15
传入turnSwitchOff,slot 40位于(4+32+32=68)bytes,满足onlyOff的要求
30c13ade -> func flipSwitch(bytes memory _data) selector
00: 0000000000000000000000000000000000000000000000000000000000000020 -> 由于bytes是动态类型,因此它可以消耗任意数量的32bytes,为了避免与传递到该函数的其他参数发生溢出和冲突,该slot实际上是指向可以找到具体bytes值的calldata slot的指针。此处,ABI 要求前往slot 20查找实际bytes值
20: 0000000000000000000000000000000000000000000000000000000000000004 -> 这是实际bytes的开始。由于bytes也是变长类型,因此该slot表示实际字节的长度。在本例中,它表示该值有4个bytes大
40: 20606e1500000000000000000000000000000000000000000000000000000000 -> 这是我们传递给函数的字节,从前一个slot中,程序知道了要取4bytes返回给_data。在本例中,程序将_data设置为20606e15
传入turnSwitchOn
30c13ade -> func flipSwitch(bytes memory _data) selector
00: 0000000000000000000000000000000000000000000000000000000000000060 -> 与之前相同意思,但告诉程序要跳转到slot 60,而不是slot 20,也就是从此处偏移96个字节(32x3)
20: 0000000000000000000000000000000000000000000000000000000000000000 -> slot 20上不需要存储字节长度,空填充
40: 20606e1500000000000000000000000000000000000000000000000000000000 -> 仍旧保留 20606e15 用来欺骗onlyOff
60: 0000000000000000000000000000000000000000000000000000000000000004 -> 这是实际bytes的开始,表示该值有4个bytes大
80: 76227e1200000000000000000000000000000000000000000000000000000000 -> 这是我们传递给函数的字节,从前一个slot中,程序知道了要取4bytes返回给_data。在本例中,程序将_data设置为76227e12
可直接传入上述calldata或使用下面的encodeWithSignature,都一样
bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";
bytes memory data = abi.encodeWithSignature(
"flipSwitch(bytes)",
bytes32(uint256(96)),
bytes32(""),
bytes4(keccak256("turnSwitchOff()")),
bytes32(uint256(4)),
bytes4(keccak256("turnSwitchOn()"))
);
编写攻击合约switch_hack.sol 编写攻击脚本switch_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/switch_hack.s.sol:SwitchHackScript -vvvv --broadcast
EVM不知道数据类型是什么,这些是 Solidity 发明和强制执行的概念
指定uint8作为registerTreasury
函数的输入参数类型会对calldata强制实施最大大小限制,但事实并非如此,使用低级汇编意味着关闭 Solidity 中的正常保护措施
在这种情况下,这意味着不再对传递给函数的 calldata 进行类型检查, EVM 看到的所有内容都是在调用数据中传递的原始字节
所以直接调用registerTreasury
并传入大于255 uint的calldata即可
从solc版本0.8.0开始,默认启用ABI coder v2,此更改添加了保护措施,以实际检查输入类型,如果solc版本>0.8.0,这个交易会被拒绝
合约会将msg.sender
设置为commander
,所以应该直接用脚本调用合约,不能通过攻击合约来,否则commander
会被设置为攻击合约地址
编写攻击脚本higher_order_hack.s.sol,其中合约地址使用ethernaut提供的合约地址
执行脚本发起攻击
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/higher_order_hack.s.sol:HigherOrderHackScript -vvvv --broadcast
这一关是一个质押合约,需要找到其中的漏洞并满足题目给出的条件:
Stake
合约的ETH余额必须大于0totalStaked
必须大于Stake
合约的ETH余额- player必须是质押者
- player的质押余额必须为0
漏洞分析:
漏洞存在于StackWETH
函数中,合约调用了WETH的0xdd62ed3e[allowance(address,address)]
和0x23b872dd[transferFrom(address,address,uint256)]
两个函数
在调用第二个函数时返回了一个transfered
,但在合约中没有对transfered
的值做判断,也就是说当transfered
为false代表了转账失败,但合约不会revert,我们可以在没有WETH的情况下直接调用StackWETH
函数
攻击思路:
- 使用EOA账户直接调用
StackETH
发送(0.001 ether + 1),再调用Unstake
函数解押(0.001 ether + 1)ETH,这样我们成为了质押者且质押余额为0,满足了条件3和4 - 使用攻击合约调用"DummyWETH"合约的
approve
函数授权一些额度来通过allowance
校验 - 使用攻击合约调用
StackWETH
发送(amount=0.001 ether + 1)来将totalStacked
增加amount,这样就可以满足条件2 - 使用攻击合约调用
StackETH
发送(0.001 ether + 1),再调用Unstack
函数解押(0.001 ether)的ETH,要留1 wei在合约内来满足条件1 - 把攻击合约
destruct()
掉,把攻击所使用的0.001 ether再转回给EOA
编写攻击合约stake_hack.sol
编写攻击脚本stake_hack.s.sol,其中合约地址使用ethernaut提供的合约地址,weth地址通过console执行await contract.WETH();
查询
执行脚本发起攻击
这一次需要加上--evm-version cancun
来指定evm版本,否则会报错[NotActivated] EvmError: NotActivated
forge script --rpc-url https://1rpc.io/holesky script/ethernaut/stake_hack.s.sol:StakeHackScript --evm-version cancun -vvvv --broadcast
Ethernaut系列完结撒花~~~
- ETHCC-2023
今天开始ethcc-2023系列,先按照github中的文档来部署
部署War
合约时,遇到了继承的Ownable
合约constructor
缺少参数的问题,这是因为我安装了openzeppelin-contracts
的5.0.2版本
进入lib/openzeppelin-contracts
目录执行命令git checkout release-v4.9
将其切换到4.9版本就不需要这个参数
本关的目标是要让其他人都无法处理已部署的合约,与ethernaut-25-Motorbike
类似
执行脚本部署合约之后有DasProxy
与Impl
两个合约
与ethernaut-25-Motorbike
题目类似都使用了UUPS代理模式,也存在Impl
合约未初始化的问题,可以直接调用Impl
合约的initialize
函数来获取owner
但是调用initialize
需要0.1个eth,得再去找点水,使用test可以自行分配一些eth
initialize
要求传入的address参数要是0,并且没有限制只能调用一次,选手们可随意抢夺owner权限
获得owner权限后就可以调用whitelistUser
函数把自己加入到白名单中
Impl
合约继承了UUPSUpgradeable
合约UUPSUpgradeable.sol,可通过调用upgradeTo
函数来升级合约
upgradeTo
有一个onlyProxy
装饰器,要求这个函数必须是通过proxy delegatecall来调用
upgradeTo
中又调用了_authorizeUpgrade
函数,这个函数是在Impl
中实现的,要求withdrawals[_msgSender()] > 1
并且msg.sender
在白名单中
之前在initialize
时已经传入了0.1 ehter,只需要调用一下withdraw
就可以满足这个条件,到此搞定了合约升级的问题
新合约不实现这些函数就可以TakeOwnership.sol
攻击思路总结:
- 调用
initialize
函数获取owner权限 - 调用
whitelistUser
函数加入到白名单中 - 调用
withdraw
函数取回初始化合约时的ETH - 调用
upgradeTo
函数升级合约
test文件Proxy.t.sol
进入warroom-ethcc-2023
目录中,执行以下命令测试:
proxychains3 forge test test/proxy/Proxy.t.sol --rpc-url $SEPOLIA_RPC_URL -vvvv
本关的挑战与闪电贷有关 闪电贷是一种无抵押贷款,只要借入的资产在同一笔区块链交易中偿还,用户就可以在没有前期抵押物的情况下借入资产,以此达到套利或操纵价格的目的 个人理解闪电贷有点类似于传统的性能测试,有些程序在并发较低的情况下运行良好,但在并发高的情况下就会有问题,闪电贷通过提供大量流动性可能发现的潜在攻击媒介
闪电贷攻击示例:
- 攻击者从支持闪电贷的协议中借用了大量代币 A
- 攻击者在 DEX 上将代币 A 换成代币 B(降低代币 A 的现货价格并提高代币 B 在 DEX 上的现货价格)
- 攻击者将购买的代币 B 作为抵押品存入 DeFi 协议,该协议使用上述 DEX 的现货价格作为其唯一的价格馈送,并使用操纵的现货价格借入比正常情况下更大的代币 A
- 攻击者使用借入的代币 A 的一部分全额偿还原始闪电贷并保留剩余的代币,利用协议操纵的价格馈送产生利润
- 由于 DEX 上代币 A 和 B 的现货价格被套利回真实的全市场价格,因此 DeFi 协议处于抵押不足的情况
也就是说最终攻击的对象是Defi
攻击思路:
- 设置
minFlashLoan
变量为1e23 + 1
确保闪电贷金额高于Aave的10000代币上限 - 通过 Aave 的闪电贷功能借入大量 DAI
- 编写攻击合约实现一个
executeOperation
函数,这个函数是Aave闪电贷的回调函数。当flashLoanSimple
被调用并且贷款资金到达后,executeOperation
就会被执行 - 在
executeOperation
中将贷款金额和利息转移到loan
合约,随后触发loan
合约进行另一笔闪电贷操作,这样就实现了循环调用 - 从
loan
合约中提取所有奖励代币 - 偿还闪电贷借款,同时保留从
loan
合约中提取的奖励
test文件Loan.t.sol
进入warroom-ethcc-2023
目录中,执行以下命令测试:
proxychains3 forge test test/flashloan/Loan.t.sol --rpc-url $SEPOLIA_RPC_URL -vvvv
这一关标题是签名可塑性漏洞,要求攻击者利用此漏洞提取合约中的资金
给定一个有效的签名,攻击者可以做一些快速的算术来推导出一个不同的签名。然后,攻击者可以 "重放"这个修改过的签名
WhitelistedRewards
合约中有一个whitelist
函数与claim
函数
whitelist
函数要求msg.sender
在白名单中才可以设置新的白名单用户
claim
函数使用ecrecover
函数来根据给定的签名和hash来恢复签名者公钥地址,要求传入hash、r、s、v这4个参数,且要求计算出的signer在白名单中
主要需要使用到flip s
技巧
// verify that the same signature cannot be used again
vm.expectRevert(bytes("used"));
rewards.claim(whitelisted, whitelistedAmount, v, r, s);
// The following is math magic to invert the signature and create a valid one
// flip s
bytes32 s2 = bytes32(uint256(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) - uint256(s));
// invert v
uint8 v2;
require(v == 27 || v == 28, "invalid v");
v2 = v == 27 ? 28 : 27;
vm.prank(user);
rewards.claim(user, whitelistedAmount, v2, r, s2);
test文件WhitelistedRewards.t.sol
进入warroom-ethcc-2023
目录中,执行以下命令测试:
proxychains3 forge test test/flashloan/WhitelistedRewards.t.sol --rpc-url $SEPOLIA_RPC_URL -vvvv
这一关提供了两个合约RewardsBox
和AccessControl
AccessControl
是一个存取控制器,指定了vitalk.eth和一个备用管理者,即使是AccessControl
的owner也无法添加新的admin
RewardsBox
合约实现了一个claim
方法,在此方法需要两个参数accessController
和amount
,判断了传入的accessController
代码是否与之前提供的相同,并且判断了msg.sender
是否被授权可以提币
也就是说只要求AccesssControl
合约代码相同,不要求是同一个地址,所以我们可以部署一个新的
这里的问题在于,EVM中部署合约时,可以在构造函数中实现任意代码,也就是creation code
,而不会影响到runtime code
,RewardsBox
中检查的是runtime code
而不是creation code
攻击思路:
- 编写一个新的
AccesssControl
在构造函数中把自己设为owner
其他保持不变 - 使用新的
AccessControl
地址传入RewardsBox
的claim
函数
test文件AccessControl.t.sol
进入warroom-ethcc-2023
目录中,执行以下命令测试:
proxychains3 forge test test/flashloan/AccessControl.t.sol --rpc-url $SEPOLIA_RPC_URL -vvvv
这一关的目标是看用户能否识别到Multiply
合约是使用Factory
部署的,这个合约允许用户花费一些代币换取合约中的代币,在用户授权Multiply
合约后,直接销毁掉Multiply
合约并部署一个新的Multiply2
恶意合约,以此盗取用户钱包内的代币
为了不让用户发现使用了不同的合约,需要利用到在同一个地址部署不同合约的技巧
通过create
部署的合约地址计算是通过contract address = last 20 bytes of sha3(rlp_encode(sender, nonce))
sender
是部署者的地址,nonce
是sender发送交易的数量,通过reset nonce值,就可以给不同的合约部署成一样的地址
test文件MultiplerRug.t.sol
进入warroom-ethcc-2023
目录中,执行以下命令测试:
proxychains3 forge test test/flashloan/MultiplerRug.t.sol --rpc-url $SEPOLIA_RPC_URL -vvvv