RemedyCTF 2025 Solidity & DeFi 相关题目分析
DX3906 区块链安全 167浏览 · 2025-02-28 02:10

TL;DR


赛事官网:https://ctf.r.xyz

Github:https://github.com/Hexens/remedy-ctf-2025

ChainLight writeup:https://github.com/theori-io/ctf/tree/master/2025/remedyctf


RemedyCTF 2025 是专注web3安全的CTF赛事,题目质量高,且贴近实际场景,非常有学习意义。本文复现了9道与Solidity & DeFi相关的题目,深入分析了题目漏洞点及攻击原理,并总结归纳了关键词。部分题目思路来自ChainLight队伍的writeup

0x00 Diamond Heist

关键词

代币重复投票、CREATE2、UUPSUpgrade

题目概览


Agent, we are in desperate need of your help. The King's diamonds have been stolen by a DAO and are locked in a vault. They are currently voting on a proposal to burn the diamonds forever!

Your mission, should you choose to accept it, is to recover all diamonds and keep them safe until further instructions.

Good luck.

This message will self-destruct in 3.. 2.. 1..


Diamond.solERC20代币,表示题目情景中的钻石

Vault.sol锁住Diamond的金库,hexensCoin投票可以进行特权操作governanceCall,合约可UUPS升级

VaultFactory.solVault工厂合约,指明Vault是由ERC1967Proxy部署的,使用saltcreate2可预先计算部署地址

HexensCoin.sol依托于HexensCoin代币的去中心化投票系统

Burner.sol执行selfdestruct销毁Diamond

Challenge合约创建了以上各合约,然后把Diamond锁进了Vault,我们的任务就是要把Diamond取出来。player可以调用claim函数获得初始HexensCoins

攻击路径

首先关注HexensCoin.sol,投票系统会验证投票者HexensCoin代币,但并没有将代币锁定或销毁,所以只要将代币转移到另一个人手上再次投票,票数就可以累加,达到刷票的效果,从而就有了调用vault合约中特权操作governanceCall的能力

Vault合约是可升级的,但合约重写了_authorizeUpgrade函数,要求合约中IERC20(diamond).balanceOf(address(this)) == 0才能upgrade,所以我们必须先governanceCall调用burn函数将Diamond转移

现在问题就来了:Diamond转移后Burner合约直接selfdestruct,我们如何还能拿到Diamond呢?

下面到了关键部分,上面说过,Vault合约创建使用了salt即CREATE2,故地址确定。而对于普通CREATE创建出的Burner合约,它的地址取决于deployer(Vault合约)地址和交易次序(第几个交易),deployer地址不会变,交易次序也能不变吗?其实只要Vault合约也selfdestruct然后再重新创建即可

注:原版paradigmctf环境不支持hard_fork参数,出题人在challenge.py中使用的hardfork="shanghai"不起作用,默认本地docker环境anvil的evm version会是cancun,而在cancun版本中selfdestruct之后并不会允许CREATE2部署新合约在相同地址上(详见EIP-4758

Exploit

NewVault合约:

NewBurner合约:

攻击脚本:

0x01 Casino Avengers

关键词

紧凑签名复用、函数返回值信息泄漏

题目概览

After numerous attacks by Alice on Bob, he's now planning his revenge. By tracing his stolen funds, Bob has uncovered Alice's latest scheme: a rigged Casino smart contract. You and Bob have a long history together. While Bob may not be an expert in hacking, he has turned to his most trusted ally - you - for assistance. Although the funds are already locked in the contract and it seems impossible to retrieve them, as a team you are determined to find a way...

Casino.sol中实现了一个赌博协议,用户可以deposit存入资金,withdraw取出资金,bet进行赌博,pause和reset为特权操作,可由管理员(signer)发起

合约存在一个后门,就是withdraw函数中使用了reciever(故意拼写错误),这个变量声明在ICasino合约里:

这会导致所有正常的取款都会失败,用户永远也拿不回自己的钱。我们的任务是找到漏洞把钱拿回来

攻击路径

首先,Casino合约处于paused状态,想要进行其他交互,首先得把合约取消暂停。想要使用pause函数,就要通过签名校验_verifySignature。这里使用了openzeppelin的ECDSA.recover库函数,并对signature去重,但这个库支持compact signature(详见ERC-2098),合约并没有考虑到,所以我们可以从区块历史中拿到用过的签名,变形成一个新的签名,就能pause、reset了

下一步就是从合约里偷钱了,重点在bet函数。对于随机数生成,虽然使用链上数据肯定不合适,但gasleft我们并不好预测,问题出在函数返回值,返回了是否成功,泄漏了信息。我们完全可以在不成功时选择revert,只有在成功时正常执行,这就能不断积累我们奖金,每次翻一倍,最后调用reset函数拿到资金。由于reset函数中使用~~~对用户资金做奇怪的计算,所以我们需要多加调试

Exploit

使用web3.py从区块历史中提取出使用过的签名,并变形成紧凑签名:

攻击合约:

0x02 Lockdown

关键词

ERC-721 SafeTransferFrom 回调函数

题目概览


“The last audit told us we didn't have enough reentrancy locks, so we put them everywhere. We're safe now, right?”

Goal: Drain the Marketplace contract of all CUSDC and as much USDC as you can.


LockToken.sol中定义了一种ERC-721代币LCK,重点关注_beforeTokenTransfer函数,在发送方或接收方为_marketplace时会有stake或unstake的特殊效果。LockMarketplace.sol中使用LCK作为质押凭证,用户可以通过存入USDC铸造LCK,每一个LCK相当于是一张银行卡,可以质押其中USDC获得收益,USDC会被兑换为CUSDC,作为合约收益来源。用户可以随时解质押、将CUSDC兑换为USDC并取出

这是一个典型的DeFi项目,我们的任务是掏空合约,拿走所有USDC和CUSDC

攻击路径

题目描述中提到关于重入锁的内容,我们就要着重去看可能发生重入的代码。合约中没有底层call操作,那么很有可能存在一些回调函数。关注合约中的transfer系列函数,可以发现ERC-721safeTransferFrom函数要求,如果接收方地址是合约,则必须实现onERC721Received回调函数,这就是我们的突破口

接下来就要思考,如果在onERC721Received回调中安排一些恶意操作,能否导致我们想要的结果?

可以插入操作的回调位于unStake函数中,下一句将会执行_swapCUSDCforUSDC(_prevOwners[tokenId], tokenId);,这里使用的是token的prevOnwer,而redeem操作时_cusdc.redeem(_cUSDCInterest[_iLockToken.ownerOf(tokenId)])使用的是现owner,在预期情况下,这两个owner都是解质押的用户。但如果用户A在收到代币的回调函数中,将代币发给用户B,用户B收到马上再发回给用户A,prevOwner就成了用户B,而owner是用户A

这直接导致了_cUSDCInterest数组取值的差异,redeem时使用用户A的余额,但扣除余额是却扣除的是用户B,用户B余额本身是0,由于合约减法保护逻辑不会revert,返回依旧是0,从而用户A可以无限redeem,积累reward

后续就可以不断偷出合约中的资金,但不能让合约不够付奖励而revert,这需要仔细调试

Exploit

完整EXP;

0x03 Frozen Voting

关键词

零地址检查缺失

题目概览

In this system, voting NFTs are equipped with varying levels of voting power, and one particular NFT holds super voting power. After minting, this powerful NFT is fortunately delegated to the player. To solve this challenge, players with a normal NFT must freeze the super voting power NFT.

VotingERC721.sol构建了一个基于ERC-721代币的投票系统,有两个代币NORMAL_IDSUPER_ID,分别有着不同的voting power。题目开始时,player拥有NORMAL_ID,且SUPER_IDdelegate给了player。我们的目标是将SUPER_ID的代币锁死,SUPER_ID的owner既不能重新delegate给其他人,也不能把代币发送给别人

攻击路径

想要锁死代币,就是要让重新delegate / transfer的操作全部revert。简单浏览一下合约,最简单的方式就是让减法操作出现溢出,如何操作呢?

关注delegatedelegateBySig两个函数,实现了同样的功能,不同的是,一个直接使用msg.sender,一个使用signature鉴权。然而delegateBySig却漏写了一个检查,即delegatee == address(0)的情况。如果playerdelegate(address(0))会发生什么?

delegate(address(0))时会减去一份NORMAL_ID的投票权重,但其他地方都使用delegates函数检查delegatee,当_delegates数组值为0时返回delegator本身,这意味着,当再次delegate或者tranfer token时,可以再次减去NORMAL_ID的投票权重

最开始时,player拥有一份NORMAL_ID+一份SUPER_ID的投票权重,此时已经减去两份NORMAL_ID的投票权重,当SUPER_ID的owner进行重新delegate或transfer代币操作时,本来要减去一份SUPER_ID的投票权重,此时已不够减,势必revert,达成目标

Exploit

完整EXP:

0x04 Rich Man' s Bet

关键词

空数组 / 空循环 检查缺失、错误数据类型转换

题目概览

"Power to the people? What a joke... Only the rich deserve power!" That's what the developer of this bridge had in mind when creating it. For him, being rich is proof of intelligence and wisdom. Therefore, for the modest price of 1000 ETH, anyone can become a validator and have a say in the bridge's configuration. He also truly believes that poor people are stupid—they will never understand the "rules" of this society, and that's why they are poor. To mock them, he even implemented this challenge: anyone who can solve it will take the entire bridge balance with them! What arrogance... He forgot that there are people out there who don't understand the rules simply because they don't play by them. We call them hackers.

AdminNFT.sol创建了一个ERC1155代币,且存在和Bridge合约的特权交互

Bridge合约主要有两套功能:

1 bridgeSettingValidators根据validatorWeights加权投票,若权重过半可更新Bridge Setting

2 withdrawValidators投票,若人数过半可触发withdraw

我们的目标是拿走Bridge合约中的所有ETH

攻击路径

无论是想更新bridge setting,还是想withdrawETH,我们都得先成为Validator。简单浏览后可以发现,只有接受AdminNFT的两个回调函数onERC1155ReceivedonERC1155BatchReceived会添加用户到bridgeSettingValidators,得从这两个函数入手

onERC1155BatchReceived中存在一个巨大的问题:没有检查ids.length为0的情况。如果传入空数组,仍会把frompush进bridgeSettingValidators,这使得任何人都可以随意变成bridgeSettingValidator,不过只有较低的权重

想要控制至少一半的权重,创建更多的账户有些麻烦。changeBridgeSettings函数中累加accumulatedWeight时还存在一个问题:对于重复签名者的检验,只要求相邻两个签名者不一致,只要我们把两个相同的签名交替重复,就能轻松绕过这个检查,无限叠加权重。于是我们就可以真正更新Bridge Setting

更新Bridge Setting时,更改合约地址其实没有意义,因为我们要从这份Bridge合约的这个地址偷钱,更改Threshold会有什么效果吗?Threshold控制着withdrawValidators投票成立的人数,且我们根本没办法获得withdrawValidator的身份,这个Threshold就是唯一的突破口

设置Threshold的值为多少是个重要的点,合约要求newThreshold必须大于1。但这却还有个巨大的问题,传入的newThreshold类型为uint256,但实际update时却转换了单位:threshold = uint96(newThreshold);。所以只要我们传入2**96,最后Threshold就会因精度损失变成0

最后我们进行withdrawEth时,只要传入空signatures数组跳过循环,就能满足条件,成功取款

Exploit

完整EXP:

0x05 Not a Very Lucky Token

关键词

可预测随机数、Uniswap V2

题目概览


You start with 1 ETH and a questionable token that offers a maybe +5% gain or a definite -10% each time you transfer it. There’s a Uniswap pool (ETH / Lucky Token) and a massive vault bursting with tokens. Your Master Plan?

Drain the pool’s ETH until its balance falls below 1 ETH (muahahaha!).

Walk away with over 10 ETH in your own pocket, proving you’re the luckiest one around.


LuckyToken.sol构建了一个ERC20代币,重点关注_transer函数,在每次transfer过程中都会生成一个随机数,根据随机数计算是赢得(mint)一些代币,还是失去(burn)一些代币。还有一个是openTrading函数,会将合约剩余的代币充入uniswap V2池子中,我们的目标就是洗劫池子中的ETH

LockStaking合约是一个质押复利的系统,重点在于有锁定期,不能提前取出。TeamVault合约是在LuckyTokenconstructor中创建的,重点关注release函数,会将代币放入lockStaking质押,withdraw函数require(block.timestamp > 1737964800)在当时比赛过程中均不可调用,不用考虑

攻击路径

Lucky Token的ICE和FIRE机制依赖于随机数,而这个随机数显然可预测,所以我们希望一直ICE增加代币。可是合约有totalAmountMinted + pendingAmount < totalAmountBurned的限制,即ICE代币数量要小于等于FIRE代币数量。想要获得一大笔代币,就必须先烧掉一大笔代币,如何做到呢?

这时我们想到了Team Vault,这里面就有一大笔代币,但是token.addSpecialReceiver(address(lockStaking), true)使这笔钱不参与ICE / FIRE。事实上,我们能干扰白名单添加的过程,因为在_addSpecialReceiver函数中,会检查balanceOf(_user),只有balance为0时才会被添加白名单

所以我们只需在添加白名单之前给这个地址转入一点代币即可,而lockStaking是通过salt部署的,即CREATE2,所以地址可预测,正好符合我们的需要。干扰添加白名单之后,我们需要控制随机数,让team vault的代币大量FIRE,即可让我们自己不断ICE增加代币。当拥有足够Lucky Token后,我们去uniswap V2池子全换成ETH即可

注:这个题目出得不好的点在于,uniswap V2池子未被添加为special receiver,最后交换出ETH时仍可能FIRE,导致交换错误,且不好排查,徒增无意义的工作量

Exploit

0x06 Unstable Pool

关键词

批量操作、除法精度损失

题目概览

Time to test your pool draining skills! Try to steal 90% of the stable coins from the pool.

PoolToken合约创建了一个简单的ERC20代币,WrappedTokenPoolToken的包装代币,二者保持一定兑换比率

UnstablePool实现了一个简单的交换池子,其中有三种代币,分别是LP TokenPoolTokenWrappedToken。池子支持单次或批量交换,支持指定投入数量GIVEN_IN或指定换出数量GIVEN_OUT两种模式

我们的目标是找到交换的漏洞,拿到池子里90%的代币,降低getInvariant

攻击路径

我们手上本身没有池子里三种任何一种代币,使用swap函数直接就会revert,所以batchSwap是我们唯一的选择。一次batchSwap需要指定统一的交换模式,所以我们只需考虑一种情况,这里考虑GIVEN_OUT

PoolTokenWrappedToken的交换过程中,始终是进1出1。经过一番分析可以发现,在投入两种代币之一、换出LP Token时,交换比率都可以概括为invar/lpSup * delta。投入LP Token、换出两种代币之一时,交换比率可以概括为lpSup/invar * delta

然而,问题在于,对于不同种类的代币交换,使用的除法取整方案不同,有mathDivUp也有mathDivDown。经过验证,存在精度损失的套利空间。经过精巧的构造,可以放大这种套利的数量,这需要大量的调试尝试

Exploit

完整EXP:

0x07 Restricted Proxy

关键词

abi encoder

题目概览


Long, long ago (like... Block 42), a wizard sealed 1 ETH inside a mystical Proxy Contract.

You get one shot to proxy upgrade it—but under these very strict rules:

No Messing with the Family Tree The inheritance structure stays exactly as is. No new parents, no secret children.

No Rewriting the Magic You can’t alter existing functions or their visibility, and you can’t add or remove any functions. No new spells, no banished spells.

No Rearranging the Royal Closet. The storage layout cannot change. Touch a single uint256, and you might awaken the alignment demon.

No Upgrading the Wizard’s Quill Keep the same Solidity version. The wizard likes his dusty old version—deal with it.

Obey these ancient laws, upgrade the contract once, and claim the 100 ETH prize. But break them and face the dreaded 'Gasless Abyss!'


CTF合约可以实现基本的withdraw功能,特别的是大量使用内联汇编。任何人都可以轻易成为owner进行withdraw,问题在于withdrawRate只能设置为uint8变量,导致无法取出全部100 ETH,无法达成题目要求

重点在于题目给出的Upgrade功能,可以进行一次合约升级,限制条件:

不能更改合约继承关系

不能更改函数

不能更改存储布局

不能更改Solidity版本

我们的目标是拿走CTF合约中的所有100个ETH

攻击路径

这道题预期是一个小trick,了解即可。仔细查看changeWithdrawRate函数:

sstore存储的是calldataload(4)整个的32个字节,而uint8类型检查实际依赖abi encoder。默认使用的是abi encoder v2,但实际在abi encoder v1中并不会进行类型检查。所以我们只需在文件开头添加pragma abicoder v1;指示使用v1版本,upgrade合约,即可随意设置withdrawRate,轻松达成目标

0x08 Tokemak

关键词

Transient Storage、Tokemak

题目概览

I found this cool protocol called Tokemak that lets you earn maximized yield on your ETH! I'd recommend pooling all our autoETH together in my new LFG Staker contract, otherwise you're definitely ngmi.

题目依托tokemak项目建立LFGStaker合约,用户可将tokemak的autoETH代币(类似于LP token)质押到LFGStaker合约中,获得收益。题目目标是耗尽LFGStaker合约中的初始资产

想要做LP的用户往往需要谨慎选择收益大的池子,且需要密切关注池子动向,以保证盈利,整个过程十分麻烦。tokemak瞄准这个痛点,提出将LP资金集中到一起,通过算法计算出收益最大的池子,并自动为用户选择资金流向,以获得收益最大化

简单来说,用户在DEX进行交易时可以使用聚合器,tokemak就是要做LP的聚合器。想要完全理解题目,最好还是把tokemak开发者文档通读一遍,这里不再赘述

攻击路径

对于这种deposit assets、redeem shares的合约,由于涉及assets和shares之间的转换,首先就得考虑是否存在除法精度上的套利。这里涉及到向上 / 向下取整。可以发现,计算向外发送代币的数量没有被放大的可能,精度上损失的代币会留在合约内,也就不存在套利机会

整个合约十分简单,只有存入和取出两条路,所以唯一的机会就在totalAssets函数中对autoETH合约的调用。如果我们能在deposit时降低totalAssets或在redeem时放大totalAssets,那么就可以套利

totalAssets函数会调用autoETH.previewRedeem,里面会有一个非常复杂的调用过程,简化来说:

1preview操作会进行low level call,最后revert,previewRedeem即call redeem

2autoETH合约查看闲置资金能否满足需求,能满足即结束

3闲置资金不足,尝试从流动性池(DestVault)中取出流动性

4取出的流动性为冲入的两种代币,但用户需要的只有一种代币(Basic Asset),需要将另一种代币转换成Basic Asset

5交换所用的池子可以自动取用,也可以用户指定

6最后将所有的代币返回给用户,Burn掉LP token(autoETH)

对应tokemak合约函数大致如下:

问题就出在最后的交换步骤,用户可以自由指定交换的池子,哪怕是一个用户编写的任意汇率的池子,这个池子直接影响着用户redeem最终拿到的资金,即也影响着totalAssets,这完全符合我们套利的需求

而决定是否使用用户指定的池子,就会查看是否设置了相应的Transient Storage变量,这就回到了本文的主题

接下来的问题就是:

1 初始资金哪里来?题目player用户初始只有1 ether ETH,套利效率太低 使用闪电贷(flashloan)借出资金,完成套利。可以使用Aave的api,有详细的文档,写起来非常方便。在实际的DeFi攻击事件中,也到处都是闪电贷的身影。也有很多攻击者使用dydx的闪电贷服务,好处是没有手续费,但文档不全,写起来比较麻烦

2 如何在同一次交易中设定Transient Storage变量,并进行恶意复用? 经过搜索发现,AutopilotRouter合约中redeemWithRoutes函数会使用swapRouter.initTransientSwap设定用户指定的customRoutes,即交换所用的池子。但在redeemWithRoutes最后会使用swapRouter.exitTransientSwap清空Transient变量,怎么办呢? 可以发现,在redeemWithRoutes中间会调用vault.redeem,而vault地址也是用户传入的,如果我们将vault设置成攻击合约的地址,在执行vault.redeem时就能重新执行我们的攻击代码,且此时customRoutes已被设置。同时,redeemWithRoutes作为router合约中的函数,对vault也没有多余的检查或使用,我们只需写一个相同签名的redeem函数即可

Exploit

完整EXP:


1 条评论
某人
表情
可输入 255