0x00 前言
最近的学习遇到了2个漏洞点十分相似的题目,但都是公链相关题目,一道题是BalsnCTF中的Draupnir还有一个是RWCTF3rd中的Billboard,分别是jwang-a和iczc师傅提供的优质赛题,主要的漏洞点基本都围绕 缓存和正常存储机制由于失败交易而导致的异步问题。Draupnir利用此为基础用合约做为漏洞利用点,而Billboard则是做了一个Dapp,利用相关的去中心化服务进行攻击。都是比较有趣的。Draupnir基于ganache-core 0day,Billboard基于cosmos框架的cve。我作为一个菜鸟也是写一些自己的理解,如有错误恳请指正。
0x01 BalsnCTF-Draupnir
链接:https://github.com/jwang-a/CTF/tree/master/MyChallenges/SmartContract/Draupnir/solution
前言:BalsnCTF当时是和WP的小伙伴们一起打的,同时撞了很多场比赛,当时就想把区块链做出来就好了,结果发现完全不会。后来听说是ganache-cli的0day。。。
不禁感叹出题人太强了。。。。每年好像出题人不一样但是总能带来新东西,尤其是拿0day出题确实太值得钦佩了。
题目说明给出了题目的整体环境是和PPPCTF一样的。(不懂什么意思 难道他们当时就用0day打的了么。
首先还是看合约。
WETH.sol
// SPDX-License-Identifier: GPL-3.0-or-later
// This is a modified version of weth9.sol that compiles under solc 0.8.9
// The contract should behave identical to original weth9
pragma solidity 0.8.9;
contract WETH9 {
string public name = "Wrapped Ether";
string public symbol = "WETH";
uint8 public decimals = 18;
event Approval(address indexed src, address indexed guy, uint wad);
event Transfer(address indexed src, address indexed dst, uint wad);
event Deposit(address indexed dst, uint wad);
event Withdrawal(address indexed src, uint wad);
mapping (address => uint) public balanceOf;
mapping (address => mapping (address => uint)) public allowance;
receive() external payable {
deposit();
}
function deposit() public payable {
balanceOf[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint wad) public {
require(balanceOf[msg.sender] >= wad);
balanceOf[msg.sender] -= wad;
payable(msg.sender).transfer(wad);
emit Withdrawal(msg.sender, wad);
}
function totalSupply() public view returns (uint) {
return address(this).balance;
}
function approve(address guy, uint wad) public returns (bool) {
allowance[msg.sender][guy] = wad;
emit Approval(msg.sender, guy, wad);
return true;
}
function transfer(address dst, uint wad) public returns (bool) {
return transferFrom(msg.sender, dst, wad);
}
function transferFrom(address src, address dst, uint wad)
public
returns (bool)
{
require(balanceOf[src] >= wad);
if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
require(allowance[src][msg.sender] >= wad);
allowance[src][msg.sender] -= wad;
}
balanceOf[src] -= wad;
balanceOf[dst] += wad;
emit Transfer(src, dst, wad);
return true;
}
}
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.9;
import "./weth9.sol";
contract Setup {
WETH9 public weth;
constructor() payable {
require(msg.value == 100 ether);
weth = new WETH9();
weth.deposit{value:msg.value}();
return;
}
function isSolved() external view returns (bool) {
return address(weth).balance == 0;
}
}
看起来像个代币题,但是又没啥关系。。
首先看到了满足的solved条件是要把转账清空。
ganache-cli这个给出的环境不同于国内,国内是开一条私链给大家用,ganache-cli是每个队/每个人开一条私链。。
他也把详细的部署数据都给出了。
server.py:
利用ganache-cli起了环境,需要注意的是每条链分配了5000ETH。
每一次ganache-cli都是从原RPC上硬分叉过来的
然后看一下合约:WETH类似一个代币的函数接口编写。deposit就是捐赠
然后你的balance会加,withdraw就是提现,且无重入,totalsupply查询我当前的balance,approve较为神奇我的理解是定义我和另一个人之间的最大转账份额 后续要用,transfer 是使用msg.sender调用transferform的一种方法,
直接调transferForm的话就是正常
有这样一个条件,所以不能直接利用自己的address ,给出了上面的那种方法。
然后就是进行转账操作了。其他没啥。并且调整了一些额度。
所以合约层是找不到任何的漏洞了。我们直接从出题人给出的完整wp开始解答。
首先跳过对EVM的介绍
主要的漏洞重点就集中在 storage的更新
那么这里我就不对前置的内容进行介绍了,其中前面分别介绍了 交易发生时,数据的存取机制,然后介绍cache,再说明了交易失败时的机制。解释了为什么ganache出现了这种异步问题,是由于其中他交易失败时跳过了cache。
之后利用几个图片来解释整个漏洞
开始翻译出题人的PPT:
他的问题就出现在了合约的这个storage存储余额并没有以同样的方式进行存储和恢复。
许多合同依赖存储来跟踪帐户状态•最关键的用途之一是记录存储了多少eth•如果我们能够打破存储和平衡的同步,我们就可以成倍增加我们的金额并轻松地耗尽储备池•让我们看看这样的攻击如何对weth9也就是题目进行攻击
从这个状态开始
首先他部署一个exp合约并且存一些钱。然后用哪些钱来买代币。
然后额外写一个特殊方法,直接让他能够发生revert就可以。然后在实现一个方法
能让他提现1 eth的同时,发生错误revert 也就导致了整个的WETH的直接流失。
接下来是画图描述
调用exploit方法
同时这里的weth合约中的 storageRoot就开始记录了。
发送体现请求,cache/storageTrie 是为weth通过这种方式的引用创建。
然后调用了导致交易revert的函数,这会导致向checkpoints push进入一个状态
开始进入revert的整个流程,cache从checkpoint弹出了,并且storageTrie内的数据也扔掉了。
尝试再次提现,ganache首先从storageTries中检查账户信息,发生miss,跳过cache直接从StorageRoot中开始获取其信息。
这里检查是否还有余额可以进行提现,这里就已经出现了存储和恢复不同步的错误了
更为原理的内容大家可以自行去查看PPT。PPT中介绍了ganache的工作模式,交易中数据的变化过程。十分详细。
0x02 RWCTF3rd Billboard
前言: RealWorldCTF中 @iczc大师傅带来的公链赛题,打破了CTF中区块链只有智能合约题的规律。十分之强,个人对于公链没有很多的研究,所以这次也是希望通过这道赛题对公链安全进行入门。
学习自:https://www.iczc.me/post/rwctf-3rd-billboard-writeup/
我们直接在源码层搜索flag相关内容可以看到有一个用来处理获取flag_msg的方法。
func handleMsgCaptureTheFlag(ctx sdk.Context, k keeper.Keeper, msg types.MsgCaptureTheFlag) (*sdk.Result, error) {
if !k.AdvertisementExists(ctx, msg.ID) {
return nil, sdkerrors.Wrap(sdkerrors.ErrInvalidRequest, msg.ID)
}
advertisement, err := k.GetAdvertisement(ctx, msg.ID)
if err != nil {
return nil, err
}
if !msg.Winner.Equals(advertisement.Creator) {
return nil, sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "Incorrect Owner")
}
macc := k.GetSupplyKeeper().GetModuleAccount(ctx, msg.ID)
if macc == nil {
return nil, sdkerrors.ErrUnknownAddress
}
if !macc.GetCoins().AmountOf(types.DefaultDepositDenom).Equal(sdk.ZeroInt()) {
return nil, sdkerrors.Wrap(types.ErrInvalidBalance, fmt.Sprintf("module account balance: %s", macc.GetCoins().AmountOf(types.DefaultDepositDenom)))
}
return &sdk.Result{}, nil
}
然后我们可以通过源码发现,这种去中心化服务,非常类似于我们的合约,只是他提供了可选的交易模式,也就是在handler.go中实现的各种处理消息的方法,
我们分析这段代码可以看出他获得flag的条件主要有以下几点:
- 需要创建一个advertisement
- advertisement对应的module账户中的钱要被转为空
我们通过创建advertisement时候查看相关的操作,
func handleMsgCreateAdvertisement(ctx sdk.Context, k keeper.Keeper, msg types.MsgCreateAdvertisement) (*sdk.Result, error) {
if k.AdvertisementExists(ctx, msg.ID) {
return nil, sdkerrors.Wrap(sdkerrors.ErrInvalidRequest, msg.ID)
}
maccPerms := map[string][]string{
msg.ID: {supply.Minter, supply.Burner},
}
k.GetSupplyKeeper().SetModuleAddressAndPermissions(maccPerms)
if err := k.GetSupplyKeeper().MintCoins(ctx, msg.ID, types.ModuleInitialBalance); err != nil {
return nil, err
}
var advertisement = &types.Advertisement{
Creator: msg.Creator,
ID: msg.ID,
Content: msg.Content,
Deposit: types.DefaultAdvertisementDeposit,
}
k.SetAdvertisement(ctx, advertisement)
return &sdk.Result{Events: ctx.EventManager().Events()}, nil
}
可以看到SetModuleAddressAndPermissions这个方法。也就是用来生成对应的module账户的。
从这里可以看到 我们最初的Module账户在创建之后就会有100ctc,但是Deposit的Advertisement中却没有。
到这里我觉得可以把整个的服务抽象为一个合约来理解
随便写下伪代码。
contract Dapp{
func new_ad(){
new ad.module(value:100)();
new ad.deposite(value:0)();
...
}
func delete_ad(){
....
}
func deposite(ad a){
a.module().add(msg.value);
a.deposit().add(msg.value);
...
}
func withdraw(ad a,uint x){
if(x>=a.deposite().balanceof())
{
a.module().sub(x);
a.deposit().sub(x);
}
a.module.transfer(a,x)
....
}
}
大概核心功能就如上。通过对发布广告增加相关的悬赏来提高其排名,然而每一个广告对应了一个module账户,此账户初始存款为100。然后在广告收到打赏之后,他的module和deposit都会计算这笔收益。要取钱的时候,他检测的是deposite中的值,看起来没有任何问题,但它也同样存在失败交易所导致的存储异步问题。
Cache就是这个漏洞中产生问题的点,这个区块链系统所用到的Cache机制,当获取广告数据的时候会首先从缓存读取,并且广告数据被修改后也会依次写入缓存和Cosmos SDK提供的KVStore存储。
如果产生失败交易,正常的数据存储 会发生revert回退。但是缓存却没有回退机制。如果我们在请求中包含一个 发起捐款的msg和一个必定失败的msg,缓存会记录下这个捐款。然后发生回退。因为他们属于一次请求。
但是你真实的数据存储并没有被改变。所以在下一次对缓存中的数据进行读取时,我们就会得到已经向广告中打赏100ctc的错误事件,从而再次使用withdraw_msg能够取出在module账户中的100ctc。
结语:其实两道题目都是非常困难的赛题,虽然看起来的漏洞利用较为简单,但其实挖掘的过程中会涉及到很多原理性知识的考查,比如数据变化对cache的操作,发生revert后,数据将如何变化。其中Draupnir是忽略了cache,而Billboard是因为这里的区块链系统实现cache的no revert性质。
我对整个漏洞的理解也仅仅是对于大体意义以及能够完成复现,希望后续自己也能去多看一看深层的原理实现包括底层链上数据存储更新实现等等。。。