背景
最近在玩起源岛链游时,发现交互的合约没有上传源码校验。其实我挺反感这种开发者的,因为在编译的时候就生成了 bytecode 和对应的源码 json 文件,只要顺手 上传一下就能大家都看到了,并且放心地使用这个合约,但是却不上传。不上传合约源码只会提高小白使用的门槛,并不会降低被攻击的风险。
过程
工具准备
目前 EVM bytecode 有很多工具可以反编译,比如 ethervm。但是他们是在线的,对于较大的合约不支持,所以需要 本地的工具,多番查找后找到了比较好用的 panoramix。
安装很简单,只需要 pip install panoramix-decompiler-abi
即可。
反编译
反编译也非常简单,panoramix 默认是主网,如果使用一些侧链比如 Polygon,则要使用对应的 rpc,方法是
export WEB3_PROVIDER_URI=https://rpc-mainnet.matic.quiknode.pro
。然后执行
panoramix 0xcF6cD657dDf8f5e0BEAa2Ad4CA9550c59A32685f
这样的命令就好了,等待反编译结果。首次运行会比较慢,它会下载签名词典让结果更可读。
结果
结果的阅读就比较繁琐了,以起源岛的合约为例,withdraw 叶子的函数签名是 db909505
,对应反编译出来的代码是:
def unknowndb909505(uint256 _param1, uint256 _param2, uint256 _param3, uint256 _param4, array _param5) payable:
require calldata.size - 4 >=′ 160
require _param5 <= 18446744073709551615
require _param5 + 35 <′ calldata.size
require _param5.length <= 18446744073709551615
require _param5 + _param5.length + 36 <= calldata.size
if not stor161:
revert with 0, 'contract is not initialized'
if paused:
revert with 0, 'Pausable: paused'
if _param1 != 101:
revert with 0, 'invalid item id'
if _param4 <= block.timestamp:
revert with 0, 'param expired'
if stor151[caller] > !stor159:
revert with 0, 17
if stor151[caller] + stor159 >= block.timestamp:
revert with 0, 'withdraw locking.'
if order[_param2]:
revert with 0, 'repeated order id'
require ext_code.size(stor153)
call stor153.verify(bytes32 param1, bytes param2) with:
gas gas_remaining wei
args sha3(caller, _param1, _param2, _param3, _param4), Array(len=_param5.length, data=_param5[all])
if not ext_call.success:
revert with ext_call.return_data[0 len return_data.size]
stor151[caller] = block.timestamp
order[_param2] = block.timestamp
require ext_code.size(stor157)
call stor157.mint(address owner, uint256 value) with:
gas gas_remaining wei
args caller, _param3
if not ext_call.success:
revert with ext_call.return_data[0 len return_data.size]
log 0x38484d77: _param2
return 1, _param2
通过上下文阅读理解,可以确定 param1
是 item_id,这个方法固定是 101;param2
是 order_id,类似于 nonce 这样的存在,用于防止重复交易;
param3
是购买的数量;param4
是过期时间,用于防止过久的请求发生重放,增加合约升级风险;param5
是参数签名。有一些外部合约调用,比如:
call stor153.verify(bytes32 param1, bytes param2) with:
gas gas_remaining wei
args sha3(caller, _param1, _param2, _param3, _param4), Array(len=_param5.length, data=_param5[all])
这里意思是 stor153
存放的地址,调用 verify
方法,传入两个参数,第一个参数是 sha3(caller, _param1, _param2, _param3, _param4)
的 结果,第二个参数是 param5
转成 bytes 数组传进去,这里的 sha3
是 keccak256
的别名。为了找到 stor153
的地址,需要查看源码的其它
地方,推测是通过 init 函数赋值的。通过查找合约里其他代码,找到全文唯一一个赋值 stor153
的地方:
def unknownf7013ef6(uint256 _param1, uint256 _param2, uint256 _param3, uint256 _param4, uint256 _param5) payable:
...
stor153 = addr(_param1)
所以我们可以找到这个合约调用 7013ef6
的方法时传入的参数,找到唯一一次调用的 txn:0xee4973c55f7be22ae75738aa69accbbc01039d1c5bf2ebd57eced1c1d53fa39e。
通过这个 transaction,我们找到 param1
的值是 0x8b239FAfaBCE0a27e62789738D0c98aDdb7B5815
,所以 stor153
的地址就是这个地址。
继续对其进行反编译 panoramix 0x8b239FAfaBCE0a27e62789738D0c98aDdb7B5815
,可以找到 verify 方法的实现:
def verify(bytes32 _param1, bytes _param2) payable:
require calldata.size - 4 >=′ 64
require _param2 <= 18446744073709551615
require _param2 + 35 <′ calldata.size
require _param2.length <= 18446744073709551615
require _param2 + _param2.length + 36 <= calldata.size
mem[128 len _param2.length] = _param2[all]
mem[_param2.length + 128] = 0
require 65 == _param2.length
mem[ceil32(_param2.length) + 224] = mem[128]
if Mask(8, -(('mask_shl', 256, 0, -3, ('mem', ('range', 192, 32))), 0) + 256, 0) << (('mask_shl', 256, 0, -3, ('mem', ('range', 192, 32))), 0) - 256 >= 27:
signer = erecover(_param1, 0, mem[128], mem[160]) # precompiled
else:
signer = erecover(_param1, 27, mem[128], mem[160]) # precompiled
if not erecover.result:
revert with ext_call.return_data[0 len return_data.size]
mem[ceil32(_param2.length) + 192 len 42] = call.data[calldata.size len 42]
mem[ceil32(_param2.length) + 193 len 8] = Mask(8, -(6784692728748995825599862402852807100777538164002376799186967812963659939840, 0) + 256, 0) << (6784692728748995825599862402852807100777538164002376799186967812963659939840, 0) - 256
idx = 41
s = addr(signer)
while idx > 1:
if s % 16 >= 16:
revert with 0, 50
if idx >= 42:
revert with 0, 50
mem[idx + ceil32(_param2.length) + 192 len 8] = Mask(8, -(0, 0) + 256, 0) << (0, 0) - 256
if not idx:
revert with 0, 17
idx = idx - 1
s = Mask(252, 0, s) * 0.0625
continue
if addr(signer) + 10240:
revert with 0, 'Strings: hex length insufficient'
mem[ceil32(_param2.length) + 288] = 'verify failed: account '
mem[ceil32(_param2.length) + 311 len 64] = 0, mem[ceil32(_param2.length) + 193 len 63]
mem[ceil32(_param2.length) + 353] = 0x206973206d697373696e6720726f6c6520000000000000000000000000000000
if unknown248a9ca3[0x5860476b5a14ec223973c49cf384357b5eb5f6e5d3c9264c3b1a3dba97f3f33][addr(signer)].field_0:
stop
mem[ceil32(_param2.length) + 370] = 0x8c379a000000000000000000000000000000000000000000000000000000000
mem[ceil32(_param2.length) + 374] = 32
mem[ceil32(_param2.length) + 406] = mem[160]
mem[ceil32(_param2.length) + 438 len ceil32(mem[160])] = mem[ceil32(_param2.length) + 288 len ceil32(mem[160])]
if ceil32(mem[160]) > mem[160]:
mem[ceil32(_param2.length) + mem[160] + 438] = 0
revert with 0, 32, mem[160], mem[ceil32(_param2.length) + 438 len ceil32(mem[160])]
好了,看到 erecover
就可以停手了,这是一个用签名恢复地址的方法,可以用来验证签名是否正确。可以参考 OpenZeppelin 的关于
ECDSA 的介绍。我们跟踪 signer
发现它最终是查找
unknown248a9ca3
是否存在这个地址,这个变量的定义是 def storage: unknown248a9ca3 is mapping of struct at storage 0
。同样地,我
们找上下文看看是哪里赋值的,最后找到这个函数:
def unknown2f2ff15d(uint256 _param1, uint256 _param2) payable:
require calldata.size - 4 >=′ 64
require _param2 == addr(_param2)
if unknown248a9ca3[unknown248a9ca3[_param1].field_256][caller].field_0:
if not unknown248a9ca3[_param1][addr(_param2)].field_0:
unknown248a9ca3[_param1][addr(_param2)].field_0 = 1
log 0x2f878811: _param1, addr(_param2), caller
stop
必须先 role 存在才能设置为 .field_0 = 1
。哎退了吧。这段代码十分像是 OpenZeppelin 里面的 AccessControl 的代码,可以参考这里大概就知道代码
在说什么了。
我们可以执行 2f2ff15d
这个函数试一试,可以 fork 一个本地节点来调试,执行
npx hardhat node --fork https://rpc-mainnet.matic.quiknode.pro
,然后就会显示本地 rpc 的地址。我们使用 python 进行调试:
from web3 import HTTPProvider, Web3
w3 = Web3(HTTPProvider('http://127.0.0.1:8545'))
account = w3.eth.account.from_key('0xxxxxxx')
params = w3.codec.encode(
['uint256', 'address'],
[w3.to_int(hexstr='05860476b5a14ec223973c49cf384357b5eb5f6e5d3c9264c3b1a3dba97f3f33'), account.address],
)
data = w3.to_bytes(hexstr='2f2ff15d') + params
res = w3.eth.call({
'to': '0x8b239FAfaBCE0a27e62789738D0c98aDdb7B5815',
'data': w3.to_hex(data),
})
print(res)
执行结果是 revert 了,返回
AccessControl: account 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000
。
又或者可以使用 hardhat 的 tracecall 插件可以很方便 debug。
npx hardhat tracecall --to 0x61609230D6A26954B67D5e5A370A9d69BcB9c1F8 --data 0xxxxx --rpc http://127.0.0.1:8545
考虑更多的攻击地方法:
-
签名重放:测试服务器请求 withdraw-flt 接口会返回 signature 等参数。但是抓包发现,服务端其实是监听了链上的事件来感知提币完成,而不经过前端 UI 发送的请求。遗憾地,deadline 经过观察只有 2 分钟的有效时间,所以这个签名只能在 2 分钟内 commit,而提币频率限制是 2 小时,服务端在 2 小时内肯定 能监听到这笔提款,所以这个方法不可行。
-
签名内容纂改:这种问题常见于使用了
keccak256(abi.encodePacked())
的签名,这种签名可以通过构造不同的参数来进行签名,比如keccak256(abi.encodePacked(1, 2))
和keccak256(abi.encodePacked(12))
的签名是一样的,这样就可以构造出不同的参数来进行签名。 详见这篇文档。 针对这个 case,它是keccak256(caller, item_id, order_id, amount, deadline)
进行签名的,所以有一种可能是通过把order_id
和amount
的位置进行位移,相当于增加了amount
的数量,并且能生成不同的order_id
绕过订单重复判断。但很遗憾地,这个合约并没有这么做,这个 法子行不通。 -
签名移花接木:通过构造数据让服务端在某些场景下也签名,构造相同的签名消息,但是修改实际使用的场景。因为这个合约签名时候并没有明确指明签名的用途,是 挺危险的,正常的合约会这么做:
keccak256( abi.encode( keccak256("WithdrawFIL(address,uint256,uint256,uint256,uint256)"), caller, item_id, order_id, amount, deadline ) )
但是很遗憾地,找遍了这个游戏的其他合约没有发现能移花接木的地方。主要还是那个 item_id 卡的太死了。