参加了ETHPanda的Web3实习计划,说是要每日打卡,这里就当作打卡暂存处吧(主要是先在这里写了方便直接复制粘贴打卡www)
说是每日笔记打卡,倒不如说更像是每笔日记打卡(笑)
2025.8.4
太久没学Web3了,今天作为打卡第一天,先稍微做点简单题热身一下吧
下面是来源于Cyber Apocalypse CTF 2025的签到题Eldorion:
1 | // Eldorion.sol |
由题意我们可以知道我们需要击败300血的Eldorion,但是每次攻击最多只能攻击100点血量,而且每次攻击会将上次攻击时间和本次攻击时间进行比对,如果本次攻击时间大于上次攻击时间则Eldorion会回满血
看起来这就是一个无底洞,但是我们可以轻松发现记录攻击时间使用的是block.timestamp
,即区块生成时间,因此我们只需要让多次攻击处于同一区块内就可以绕过时间检测,同时需要注意不要Overkill,否则攻击会失败
1 | // attack.sol |
部署后调用attack()
即可完成本题
是不是有点太水了,那就接着再水一题吧,是同一个CTF的第2题HeliosDEX:
1 | // HeliosDEX.sol |
由于比赛已经过了很久了,我也不知道自己手上有多少初始ETH,假定是0.1ETH吧,同时为了方便在Remix调试(我懒得自己搓一个Foundry script了),稍微降低一下Setup中的参数,最终根据实际情况预定是传入99 ether(考虑到gas费无法传入100 ether),初始参数同样设置为99,
isSolved
等比修改为2 ether
这题提供了一个能交换3种ERC20的DEX,每种代币都有其独特的汇率,最后的refund只能兑换1种代币,从合约来看自然是HLS汇率最好,能和ETH达到1:0.1的汇率;除此之外,兑换代币和refund的时候都会收取2.5‰的手续费
观察一下合约,refund被一个reentrancy guard修饰,且只有该函数能转出ETH,因此重入攻击打不了,所以想解题只能尽可能多兑换代币
观察三种代币的兑换方式,我们发现它们都使用了OZ的mulDiv
函数,但是其参数中的Math.Rounding
十分可疑,因此我们查看OZ的合约源码:
1 | library Math { |
那么我们会发现兑换ELD,MAL,HLS时,计算得到的代币数量分别会向下取整,向上取整,向上取整(由于不可能兑换负数个代币),因此我们可以仅使用1 wei兑换1个MAL或者1个HLS;同时由于整数除法导致的精度损失,我们兑换代币的数量只要不到400,就能忽略手续费,因此我们可以编写如下的攻击合约:
1 | // SPDX-License-Identifier: MIT |
需要注意一下,对于原题,使用for循环可能导致gas费消耗问题,可以稍微更改一下,此处仅作为思路提供
同时在最后进行refund前需要首先向DEX进行Approve操作,否则DEX的
transferFrom
会失败导致revert,同时由于转钱是转给msg.sender
,因此需要给合约添加receive()
函数接收转账,并添加提款函数提走合约内的资金以完成本题
行吧,就这样了,开摆~
2025.8.5
凌晨学完,白天就不用学了(确信)
接着看看CACTF的压轴题EldoriaGate吧:
1 | // EldoriaGate.sol |
由题意我们需要使得checkUsurper()
返回为True,而与之对应的是要让我们的role为0,其默认的值为ROLE_SERF
即1
说句实话题目合约真没有什么能让我们交互的,因此说白了就是通过调用enter()
来实现修改role的操作,那就让我们一步步看:
首先会进行一次authenticate(bytes4 passphrase)
去匹配密码是否正确,而密码被存放在Kernal的Storage Slot 0处,因此我们直接用cast去获取指定存储槽内数据即可
通过认证后会调用Kernal的evaluateIdentity
,而其中的contribution
参数为我们发送的msg.value
,好巧不巧Kernal中的实现里面判断role是通过add(defaultRoleMask, _contribution)
实现的,虽然后面有lt
小于判断,但是由于使用的是assembly,而且role是uint8数据类型,因此会存在整型溢出,所以只需要让_contribution
为255就能让我们的role为0,从而解出本题
由于使用了非合约操作进行解题,这里就不放解题合约了,总之就是使用cast storage --rpc-url "your rpc url" "kernal address" 0
读取Storage Slot获取密码后调用enter{value: 255 wei}(password)
即可
暂且这样吧,明天开始复现一下那些纯Blockchain的CTF题
以防有人说我水,这里再放一个RemedyCTF最简单的题(除去签到题)Diamond Heist的Writeup,附件这里就不给出来了,6个合约杀了我吧(
这道题总共有6个合约,看起来非常恐怖,但是实际上是所有题目里面解出数第2高的(第1是签到)
- 首先分析Challenge.sol,里面的要求非常简单:获取所有的Diamond代币即可,然后初始给了自己10000 ether的HexensCoin代币,并且将所有的Diamond代币转到了Vault里
- VaultFactory.sol和名字一样,就是一个合约工厂,通过salt生成Vault合约,所以使用的是
CREATE2
操作码,至于使用的salt在Challenge.sol里面有,此处按下不表 - Diamond.sol很直白,就是一个普通的ERC20合约,知道这一点就行
- Burner.sol,一个销毁用合约,很直白
- 接下来是两个核心合约:首先来看HexensCoin.sol,这个合约也是一个ERC20合约,唯一的差别就是它添加了一个delegate机制(委托机制):根据委托人所持有的HexensCoin数量,增加被委托人的Votes(即票数),而用户所持有的Votes数量仅在调用
_moveDelegates
函数的时候进行更新,且获取Votes仅会读取上一个Checkpoint的Votes数 但是问题就在于委托人将自己持有的HexensCoin通过transfer转移给其它用户的时候,被委托人的Votes仍旧不会发生改变(缺少了beforeTokenTransfer
的override),因此这会导致Votes数可以刷上去,比如统一给A刷票,那首先让B调用delegate(address(A))
后将自己的HexensCoin转给C,再让C进行delegate,以此类推 - 最后是Vault.sol,这是一个UUPS可升级代理合约,说明我们可以对这个合约进行升级,同时可以指定合约的implementation,也就是说我们可以尝试通过升级这个合约来达成恶意的操作;至于
governanceCall
我们可以通过刚刚提到的刷票方法进行调用,而burn
的话则是可以让我们“销毁”掉Vault中所有的Diamond,至于为什么带双引号这里先卖个关子
注意看题目附件中的Challenge.py,里面说明本题区块链的分叉为Shanghai分叉,也就是说此时selfdestruct
仍然可以在constructor以外被调用时删除合约字节码,因此我们可以销毁掉Vault再重新部署一个新的Vault并初始化,这样我们就拥有了金库的所有权;同时这个金库还可以升级为添加了恶意函数的金库,从而达成获取所有Diamond代币的目的
接下来我们回收一下伏笔:Burner自毁的操作,实际上只销毁了所有的ETH(甚至也没有,因为参数是payable(address(this))
,也就是把ETH发送到本地址后销毁,ETH还在这个地址),而ERC20并不会随之消失,因为ERC20本质上还是一个合约,而ERC20的数量本质上也是合约的一个mapping的value,因此“被销毁的Diamond”仍然保留在创建的Burner的地址中
那么此时就到了高潮部分:已知创建Burner的时候使用的语句是new,说明使用的是CREATE操作码,而nonce是1(即Burner是Vault创建的第1个合约),所以如果我们重新创建的Vault的地址是一样的,那么创建的Burner的地址也是一样的;而创建Vault的时候使用的又是CREATE2,合约工厂地址固定、salt已知,所以我们只需要控制新创建的Vault的init_code(也就是constructor部分)一致就能保证新建的Vault一定是原先的Vault的地址
因此我们可以给原先的Vault添加2个新的函数:(x.sol
)
1 | // SPDX-License-Identifier: UNLICENSED |
我们这个恶意的Vault添加了自毁函数以方便我们重新部署合约并获得owner权限,同时添加了new_sender
函数创建并使用我们恶意的Burner合约将“被销毁”的Diamond重新发送给恶意Vault并最终发送到我们自己手上
恶意的Burner合约如下:(Burner2.sol
)
1 | // SPDX-License-Identifier: UNLICENSED |
那么至此我们的攻击路径就基本完成了:
可能有人会问:为什么要销毁再创建新的Vault?我升级后直接调用new_sender不就好了?欸,还记得CREATE操作码的nonce是什么吗?是这个合约已创建的合约数+1,也就是说如果此时调用new_sender那么nonce是2,那么我们是无法获取到Diamond的(因为压根就不在那),销毁是为了重置这个nonce,就这么简单
那么这里就给出一个(应该能够运行的)脚本吧,别被吓到了,大部分都是合约的ABI,核心逻辑在后面
1 | from web3 import Web3 |
2025.8.6
欸,RemedyCTF还有一个最简单的题目Rich Man’s Bet,下面是Writeup(附件也不给了)
这题相比前面的Diamond Heist,合约数量大幅降低,但是作为一个治理合约这题难度也不低(虽然也是并列第3高解出的题目)
那么还是老样子,先分析一下合约内容:
- AdminNFT.sol是一个基础的ERC1155合约,这种合约支持多个代币的存储和批量转发
- Challenge.sol里面包含了我们的最终目标:完成“挑战”并且将跨链桥合约的余额全部转走(虽然这题并没有也不需要跨链),至于挑战就是非常简单的3个数学题,随便代进去几个数就做出来了
- 核心合约Bridge.sol是一个ERC1155Receiver合约,说明这个合约支持ERC1155的多代币批量转发操作,同时我们也能看到有个
onERC1155Received
,即接收到ERC1155转入的代币会自动调用该函数,同理还有个onERC1155BatchReceived
会在接收到批量转发的代币的时候被调用 当然作为治理合约,肯定有治理逻辑,这里就是通过NFT的Power进行治理的:Admin持有的NFT的Power是10000 ether,而一般的NFT只有50 ether,而更改跨链桥设置需要所有签名人的Power大于总数的一半且都为Validator,而最终的withdrawEth
则需要所有支持提款的总人数超过阈值(Challenge.sol设定为10)且都在初始设定的withdrawValidator
名单中,但是很明显除了合约自己我们谁都不在名单里面…
好的,现在让我们开始做题,首先我们可以很快得到三道数学题的一组合法解,解出来后在Bridge中验证挑战即可完成isSolved
的前面2个要求:
1 | def main(): |
接下来我们进行提款,上面提到了没有人能够提款,因此我们只能尝试让threshold变为0,而唯二能更改threshold的值的地方只有constructor和changeBridgeSettings
两个地方,也就是说我们就是得更新设置,那么我们有需要成为Validator,而能成为Validator的地方就只有两个onERC1155(Batch)Received
了,也就是说我们就是得向Bridge转入代币…吗?
仔细查看ERC1155的实现,我们发现如果id和amount两个数组为空数组(即转入0种代币)也是会正常调用目标ERC1155Receiver的onERC1155BatchReceived
函数的,而Bridge中并没有检测转入的代币种类是否为0,所以我们只需要转入0种代币即可:
1 | make_validator = lambda acc: send_transaction( |
然后进入到changeBridgeSettings
的部分,我们发现验证签名是否有效的时候只会记录lastSigner
并和其比对,那么我们只需要有至少2个签名就能进行无限的验证,从而达到刷Power的目的,因此我们只需要再新创建一个地址并让它签一下名就行了,当然前置是需要这个地址也成为Validator;修改的设置中我们只需要修改threshold,但是这个新的threshold必须大于1…看起来没有办法,但是仔细看一下函数传入的数据类型:是一个uint256,而最后使用的threshold是一个uint96,中间进行了一次显式类型转换,也就是说如果我们传入的数很大,类型转换会产生数据丢失,比如我们传入的newThreshold是$$2^{96}$$,那么最后我们会有threshold = uint96(newThreshold) = 0
所以我们根据上面的理论更新完设置后直接withdrawEth就行了,签名列表为空就行,最后的脚本如下:(有些多余的ABI可以删掉,因为没有调用到)
1 | # 3f716403f4394aa3c38997b1aeebed19 |
老传统,再接一题吧,同个比赛的题目Frozen Voting:
根据提供的题目合约,我们可以知道ADMIN在mint了一个10000票权的NFT后delegate给了我们自己,同时我们自己有一个1票权的NFT,此时我们的票权为10001;而最后的isSolved
会尝试更换delegate并转移走10000票权的NFT,而我们的目标就是阻止这次操作
那么其实很明显了:我们唯一能做的就是在第一步更换delegate的时候做手脚,而NFT在发生所有权变更的时候一定会调用_delegate
,而其中的_moveDelegates
会减去上一任delegatee的票权并加到下一位那里,因此很简单:我们要想办法通过_delegate
让自己的票权小于10000,从而触发整型下溢以实现DoS攻击
经过一点简单的代码审计,我们发现delegateBySig
函数有点小小的问题:相比delegate
函数,它缺少了对delegatee
参数是否为address(0)
的确认与操作,从而使得我们可以将自己的NFT通过delegateBySig
函数delegate给0x0地址,而根据_moveDelegates
函数,此时我们的票权会-1,而没有人的票权会上升(合约误以为我们销毁了NFT),因此此时我们再一次进行delegateBySig
或者直接transferFrom
给任意的地址即可让我们的票权达到9999,进而实现前面的目的
下面是forge的解题script:
1 | // SPDX-License-Identifier: UNLICENSED |
2025.8.7
我靠昨天看完沪赛的题目睡过去了,好悬没签上到
今天稍微摆个烂,就只看1题吧,RemedyCTF的Lockdown:
整个题目给我眼睛都看花了,现在还是有点懵,这题能有23解是不是有点变态了(
整个题目设计了一个能质押NFT并获取质押奖励的市场和一个对应的NFT合约,同时大量使用了安全的运算函数以及重入哨兵,代码量也不小,乍看起来十分唬人
但是在Marketplace合约中,我们发现Unstake
函数在safeTransferFrom
后会进行swapCUSDCforUSDC
操作,而传入的参数recipient
(prevOwner
)和函数中的_iLockToken.ownerOf(tokenId)
(NFT实际持有者)可能会不一致,最后导致本应该发放给NFT持有者的奖励被发放给prevOwner
,因此我们可以从这里开始下手
同时我们还发现Token中对beforeTokenTransfer
的实现存在问题:如果from
或者to
为市场本身的时候,则不会修改prevOwner
,因此假如我们已经让prevOwner
获了一次利,此时我们直接再质押并取消后,就能产生双倍利润;除此之外,只有beforeTokenTransfer
会修改prevOwner
,因此我们需要首先进行一次带onERC721Received
的重入操作,在第一次质押并取消的时候触发重入,让prevOwner
修改为我们一个Alt Account,此时我们的prevOwner
在取消质押的时候就能设置一个虚空的_cUSDCInterest
后获取质押奖励;第2次则不需要重入,直接质押并取消即可再次获利,从而翻倍奖励
这里附上榜一大哥们公开的解题脚本,我到现在还有点懵圈所以就没有自己写QAQ
1 | // SPDX-License-Identifier: UNLICENSED |
2025.8.8
今天想摆烂,就不刷题了,想着下周要搞个dApp,干脆顺带把下一届新生赛的题目一起出完好了,没几个月了
今天题目设计的进展也不多,就是跟着Chainlink文档搓了个D100用于NFT抽卡,基本上照搬的,不过只是获取随机数而已,谁还不是套个板子就开始跑呢?(笑)
D100在Ethereum Sepolia上的地址:0xA6FAca145A93DC2fAcd749B7303ef950ba2A6d81
,已经在Etherscan上Verify了源码,这一个Version还没有添加重置骰子的操作,之后会添加(不然一个人就只能抽1个NFT有点太尴尬了www),后面可能会添加NFT稀有度合成的操作,不过那些都是后面的事了,还得想想在哪个不起眼的地方塞个漏洞进去(o-ωq)).oO
Chainlink VRF这个投骰子耗时挺长的,1分钟,还得想想前端页面该怎么搞才能算合理,总不能像碧蓝航线搞个造船时间吧(
顺带一提,Remix的Verify插件挺逆天的,需要你传入正确的constructor才能Verify,结果传进去一个差不多1e76的数告诉我Overflow(2**256>1e78),但是在数前面加个0就好了,气笑了
运气不好,自己只抽到个R,笑死
2025.8.9
周末了,姑且休息一天,明天再战
随便看了一下账户抽象的一些Approach(文章链接:Account Abstraction: Past, Present, Future),准备开始啃EIP了,刚好Pectra已经在使用EIP-4337了,说不准还可以实战测试一下
说白了就是用户对持有的资产的管理和行为不够灵活,所以想要实现所谓的“账户抽象”,最终的目的是让钱包能够执行自定义的代码,而途径无非就两种:
- 让智能合约能够“主动”发送交易(一般的合约不能主动发送交易,它只是一系列确定且公开的执行逻辑,也就是一个没有电源的电路)
- 让EOA能够执行代码(一般的EOA的
extcodesize
为0,这也是过去判断EOA地址和合约地址的一个重要依据)
至于对每种途径提出的EIP等明天再开始看,现在该睡觉了
2025.8.10
嘻嘻,昨天口糊了,Pectra使用的是我们的EIP-7702而非EIP-4337(人家现在还只是草案阶段呢,不过确实是被广泛接受了,23年就已经在主网部署了Entrypoint.sol
)
今天先简单写一点ERC4337吧
交易过程的差异
对于一般的EOA账户交易,我们都是使用EOA的私钥对交易数据进行签名,然后账户将这些签名数据发送到以太坊节点进行验证和执行,验证后在链上执行操作
这里就有很多问题:首先就是经典的“私钥即一切”的问题:丢失了私钥那么就彻底丢失了资产以及相关的控制权;同时交易的支付代币只能是ETH,签名算法只能是ECDSA,EOA不够灵活…
因此有人提出了个想法:将Owner(资产持有者),Signer(交易发起者,签署交易)和Gas Payer(手续费支付者)从现有的框架中解耦出来,最终得到的一个结果就是ERC-4337
在此之前有EIP-2938及EIP-3074等提出了解决方案,但是有些方案是中心化的,有些则需要从共识层协议进行改动,需要进行硬分叉,而ERC-4337是在原有框架下实现最大程度的AA的一个方案
ERC-4337新提出了一个叫做UserOperation
的一个类交易结构体,里面包含一系列类似普通交易的参数,比如常规的sender
和nonce
之类的,但是其中有三个新的概念:Bundler
,PayMaster
和Factory
,这里我们稍后提到
这里的UserOperation
需要进行签名,不过是可以以其它的签名方式进行签名的,而不仅仅是ECDSA,在签名后会将签名数据发送到一个独立的内存池(Alt mempool
),这个内存池只存放UserOperation
,然后会有一些支持ERC4337的以太坊节点从池中选出若干个UserOperation
进行打包,这些节点就是Bundler
(打包者)
验证签名合法性的逻辑需要提前编写在AA Wallet(Account Abstract Wallet,账户抽象钱包)中,以便之后验证签名的合法性
目前为止我们的交易还在链下,接下来就要进行链上操作了:Bundler
在打包后会将这些打包后的交易发送到一个Entrypoint
合约中进行上链操作,流程如下:
- 检测AA Wallet是否存在,如果不存在且
initCode
非空则会通过factory
地址进行一个新AA Wallet的部署 - 调用AA Wallet的函数验证签名合法性
- 检测
PayMaster
是否存在抵押在Entrypoint
的代币(以防止DoS攻击),且PayMaster
余额是否足以垫付交易 - 调用
PayMaster
中的函数进行支付检查,比如AA Wallet是否有足够的余额进行交易操作 - 执行
UserOperation
中的主要逻辑 - 返还
PayMaster
垫付的费用,并向节点支付打包费用
需要注意的是:每条链上的
Entrypoint
合约是唯一的,主网上的合约地址和实现:Entry Point 0.7.0 | Address: 0x00000000…6f37da032 | Etherscan
通过这种方式,我们可以直接实现自定义签名算法、多签钱包、多笔交易执行、多代币支付手续费等便捷操作,但是由于这种方案一定会发生合约调用,因此gas费会更高,因此其中一个优化方案是利用Layer2
2025.8.11
昨天写了AA的其中一类方案:升级智能合约钱包,使其能够主动发送交易,那今天就写一下另一类方案吧,就选比较有代表性的EIP-7702吧
昨天有关
Bundler
的表述有些不清楚,实际上那些打包的节点并非以太坊RPC节点,它们只是一系列白名单中的实体,它们只负责将打包后的UserOperation
发送到以太坊节点中,可以简单理解为矿工至于zkSync链原生实现的AA这里就不谈了,交易大致流程是一样的,只是
Bundler
同时也是以太坊节点
EIP-7702提出了一个新的交易类型0x04(另外三种交易类型0x01~0x03分别对应EIP-2930,EIP-1559和EIP-4844),该交易类型会修改指定地址authority
的代码为0xef0100 || address
,其中0xef
为EIP-3541中定义的被禁用的操作码,这里用于指代执行该地址(记为A)的代码时和执行一般代码要有所区别:需要读取指定地址(记为B)的代码后再在A的上下文中执行(很显然汇编底层上使用的是DELEGATECALL
操作码)
整个EIP-7702本质上就是将一个EOA包装成了一个Proxy,而交易类型0x04就是一个setImplementation
函数,调用EOA地址的代码本质上就是在和一个Proxy交互,本身是挺不错的,不过如果同一个地址通过EIP-7702进行多次代码设置,那么执行代码的时候会不会出现和delegatecall()
一样的问题呢?比如经典的存储内变量排布问题…
除此之外,由于部署合约时initCode
是即用即删的,因此实际上我们无法在设置代码的同时进行初始化,而是必须要在设置结束后再额外进行一次初始化,这可能会是一部分额外的gas费用
好消息是经过EIP-7702设置代码的EOA地址,其EXTCODESIZE
大小固定为23(len(0xef0100 || address)
),且CODESIZE
返回的为指定地址address
的CODESIZE
,因此本质上extcodesize检查稍微改动一点还是勉强能用的,但是如果攻击者通过汇编构造合约代码的话…那就另当别论了(笑)
根据EIP规范文档,整个EIP-7702交易的Gas费用消耗为21000 + 16 * non-zero calldata bytes + 4 * zero calldata bytes + 1900 * access list storage key count + 2400 * access list address count
,额外加上PER_EMPTY_ACCOUNT_COST * authorization list length
以及对交易执行过程中特定地址的冷/热读取费用
说了这么多,实际上整个EIP-7702是一个非常大胆的尝试,因为让EOA本身能够执行代码逻辑这件事就很危险,攻击者可能会通过一系列伪造执行恶意操作(比如设置一个智能钱包,但是你往里面存钱就会调用某个EOA的代码从而窃取虚拟资产之类的),而且本身此方案如果涉及到跨链层面还会有更多问题,因此可能需要额外的EIP进行f
2025.8.12
这一周刚好从今天开始连续5天都有事没啥空,希望能尽量挤出点时间来水下签到,别似了(笑)
想到自己之前的博客还有坑没填完,这里填一下,之后找个时间从这里填回去,今天就只写一个只读重入(Read-only Reentrancy)攻击吧(其它的重入攻击或多或少写过一点,除了Double Entrypoint)
漏洞合约和攻击合约源码来自DeFiVulnLabs/src/test/ReadOnlyReentrancy.sol at main · SunWeb3Sec/DeFiVulnLabs
本质上重入攻击的攻击点就是在于合约状态变量和现实情况之间的差别,通常是合约状态变量没能赶上现实情况的变化,从而导致攻击者能够利用虚假的变量值进行一些操作
只读重入也是类似的,差别是漏洞合约过分信任被view
修饰的函数,比如主网上的案例:Lido: Curve Liquidity Farming Pool Contract | Address: 0xDC24316b…Ea0f67022 | Etherscan
假定我们有一个依赖于上面的合约的get_virtual_price
函数提供奖励的质押合约:
1 | // VulnContract |
那么我们可以通过往Pool先add_liquidity
然后remove_liquidity
,此时根据Pool源码,我们发现Pool会首先burn掉代币然后进行转账,那么此时由于total_supply
降低,此时会导致get_virtual_price
临时升高,最终导致我们的getReward
返回更多的奖励
中途虚拟价格发生的变化就是这样的表格:
阶段 | LP代币总量 | 基础代币余额 | D值 | 虚拟价格 |
---|---|---|---|---|
初始 | X | Y | D0 | P0 = D0/X |
销毁后 | X’ = X-ΔX | Y | D0 | P1 = D0/(X-ΔX) (临时升高) |
转账后 | X-ΔX | Y-ΔY | D1 ≈ D0*(X-ΔX)/X | P2 ≈ D0/X (恢复) |
最终的攻击合约如下:
1 | contract ExploitContract { |
还是那句话:Don’t trust anyone
2025.8.13
这几天没啥时间,随便水点东西得了
放篇我自己的博客好了,刚刚稍微补充了一些东西,至于这篇博客的续集等10月末再更新了:区块链靶机小记 | 9C±Void’s Blog,有点长,这里就不展开了,you’re welcome
2025.8.15
昨天坐牢太累了,回酒店倒头就睡了,今天接着坐牢也没啥空学,随便写点吧,这次是EIP-4844
以太坊L1上的交易消耗大量gas这件事已经是显然的,所以出现了大量L2 Rollup对这一点进行改善,但是L2在打包数据并发送至L1时,存储大量数据本身成本就很高,因此提出了Blob Transaction的概念
Blob(Binary Large Object)数据大小至少为4096*32字节,数据仅保留4096个Epoch,之后会被自动删除以降低存储成本,同时Blob数据被保存在共识层,因此EVM无法直接访问此数据
新提出的Blob Transaction交易类型号为0x03,它的TransactionPayload
为:
1 | [chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, to, value, data, access_list, max_fee_per_blob_gas, blob_versioned_hashes, y_parity, r, s] |
基本和EIP-1559类似,只是多加了两个和blob相关的值:
max_fee_per_blob_gas
:用户愿意为每单位Blob Gas支付的最高费用blob_versioned_hashes
:Blob承诺的版本化哈希数组,格式为0x01
+KZG承诺的sha256后31字节
由于Blob无法被EVM直接访问,因此需要使用一点密码学魔法(KZG多项式承诺)来验证数据的完整性和可用性,为此以太坊在Dencun硬分叉加入了BLOBHASH
和预编译的POINT_EVALUATION_PRECOMPILE_ADDRESS
来进行Blob完整性验证
Blob具有一个独立的Gas市场,其中每个Blob固定消耗0x20000 Gas,同时每个区块Blob目标数只有3个(最多6个)
2025.8.17
这几天准备开始细看代理模式,今天就随便放个引子好了
众所周知,以太坊中对合约函数的调用是通过函数选择器(Function Selector)进行函数的区分的,计算方法也很简单:bytes4(keccak256("Function Selector String"))
,不过由于这个选择器长度只有4个字节,那么会有很大概率存在哈希碰撞问题,也就是说一个合约中可能存在两个函数,它们的函数选择器是一样的,此时Solidity编译器在编译合约的时候会直接抛出TypeError
错误,由此杜绝同一个合约中函数调用模糊的问题
一切都非常美好,直到简单代理可升级合约的出现:我们都知道这类合约分为专门存储数据的Proxy(代理)和专门实现逻辑的Implementation(实现),一般对数据的操作都是在Proxy上通过delegatecall调用Implementation上的函数,而Proxy有的时候也会有自己的一些函数,那么问题来了:假如Proxy上和Implementation上分别存在一对Selector相同的函数,会发生什么呢?
一般来说,Proxy的delegatecall放在fallback()
中,这是因为Proxy并没有这些函数,所以合约遇到了自己没有的Selector就会自动调用fallback()
,那么如果Proxy它有呢?那肯定是优先调用Proxy中对应的函数,而如果我在这个函数中进行一些恶意操作,那么用户在使用合约的时候就会遭受损失
接下来给一个样例(出自Beware of the proxy: learn how to exploit function clashing - Security - OpenZeppelin Forum):
1 | pragma solidity ^0.5.0; |
实际上,burn(uint256)
和collate_propagate_storage(bytes16)
两个函数的选择器是相同的,因此如果我们想要调用Implementation中的burn(1)
,实际上会先首先调用Proxy的collate_propagate_storage(0x01)
,然后根据合约逻辑调用Implementation的transfer(proxyOwner, 1000)
,本来只想销毁1个ERC20的,这下倒好,1000个币全给转走了
以上就是所谓的函数碰撞攻击(Function Clashing Exploitation),也正是因为有这类攻击被发现,现在已经出现了一个专门应对这种攻击的代理模式:透明代理模式(Transparent Proxy Pattern),至于细节等之后再写
2025.8.18
摸鱼,随便写点
一些常见的可升级代理合约使用的代理模式:
- 简单代理模式
- 透明代理模式
- UUPS代理模式
- 信标代理模式
- 最小代理模式
- 钻石代理模式
前面也已经写了一点简单代理模式的函数碰撞问题,除此之外还有delegatecall经典的存储碰撞问题,总之问题很多
透明代理模式则是在Proxy的fallback中检测msg.sender
,admin发送的call永远不会被delegate,而其它地址永远都会被delegate
但是这也有很多问题,比如admin也想调用Implementation,而这会直接导致revert,所以有个方案是将admin权限转移给另一个地址App,让App进行admin操作,但是这样会消耗大量gas,而且用户无法访问Proxy的读取方法了,比如读取Implementation的地址等
UUPS代理模式相比前两者,其合约升级功能被放在Implementation中而非Proxy中,这样Proxy就是一个单纯的转发call的合约,从而减少gas消耗,同时由于所有函数功能都在Implementation中,因此也避免了函数碰撞的问题
信标代理模式是在多个Proxy使用同一个Implementation时的一个可升级解决方案:通过将多个Proxy指向一个Beacon,然后Beacon进行Implementation的升级操作,这样就可以避免在Implementation升级的时候挨个修改Proxy
最小代理模式很简单,就是从字节码层面压缩Proxy的功能,使其只保留最简单的delegatecall方法,从而减少gas消耗
钻石代理合约则是一个可以指向多个Implementation的一个Proxy,其中每个Implementation被称为钻石面(facet),但是相比于前面的代理模式,钻石代理模式支持模块化的调用,即它能够将用户限制到只和特定函数交互而非整个合约进行交互,当然,由于其复杂性,它的安全审计问题也很困难
2025.8.20
昨天开摆了,今天也想摆,水道题吧,前几天LilCTF的区块链题目“生蚝的宝藏”
其实放题当天中午12点半就做出来了(因为在线下坐牢没事干),不过因为是借的别人的靶机打的所以也就没交(借我靶机的是我学弟,他没有提交本题flag,因此不存在影响比赛公平性的情况)
简单来说就是通过Ethereum JSON-RPC API获取指定合约的Runtime Bytecode,然后反编译进行逆向分析(Dedaub我的神),基本上扔给AI都能知道是进行了一次异或操作,而异或操作可逆,所以找到key和密文就行了
密文好找:直接根据动态数组在Storage的存储方式去读就行了(没记错的话index是固定的0x5d);而key作为constant不是很好读,好在Dedaub能搞出TAC(Three Address Code),里面能搞到key,直接异或然后调用验证函数就行了
具体的Writeup可以自行去官方Writeup(Docs)中查看,此处就不过多赘述了
OK,以上为预期解,接下来就来到我们的非预期解时间~
可能有人会觉得有点事后诸葛亮的成分在,我也是在预期解完成本题后才发现的非预期,不过这里还是写上吧
此处使用ciphertext
指代存放在数组中的密文,encrypt()
指代完整的异或加密函数
反编译啥的不能省,但是我们可以发现验证函数的逻辑是比对keccak256(ciphertext)
和keccak256(encrypt(input))
这两者,而ciphertext
是在constructor(string memory initializetext)
中通过encrypt(initializetext)
得到的,因此我们的目标就发生了偏移:我们不需要知道encrypt
具体在干啥,我们只需要知道constructor
传入了什么就行
题目很贴心地给了合约创建的txn hash,因此我们可以直接读取交易的完整数据:cast rpc eth_getTransactionByHash 0xtx_hash -r http://106.15.138.99:8545
最后得到的数据就是完整的合约字节码,包括Constructor Bytecode和Runtime Bytecode,除此之外最后面是传入constructor
的参数值,也正是我们想要的东西(这里给出我当时做题时得到的snippet):
1 | ...600052601160045260246000fd5b506001019056fea264697066735822122021ebf24dde1fd17fcdd77fedee95a480bc9861c8c6717cb4263cf1512b7e7bc464736f6c634300080900330000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002e34353431333534383435343933313566353536653634333337323566373434383435356637333333343033663764000000000000000000000000000000000000 |
最后这段很明显是一个string memory
的类型,其中2e
表示了string的长度,后面的就是传入的string,因此我们直接把字符串传进验证函数里就行了
由此我们可以发现,区块链的公开性对于这类题型的摧毁还是挺大的,很多情况都能找到一种“奇技淫巧”脱离出题人的预期解,果然出题时还得好好预防这方面的操作
总的来说作为中等难度还算OK,只是一开始啥源码都不给这一点确实拦住了很多人,很庆幸当时很迅速地做出了直接使用cast获取字节码这一操作,节省了很多无用时间
2025.8.21
摸鱼,把一年半之前挖的坑(CTF区块链入门指南)给埋上了:CTF的Blockchain方向入门指南…? | 9C±Void’s Blog
唯一的遗憾就是不会Move啊Rust啊Cairo啊啥的,只能写点Solidity了〒▽〒
2025.8.23
已经不知道写啥了,好困啊…
水道VNCTF2025的题目Ekko
1 | // contracts/ZDriveContract.sol |
这题的isSolved很简单:让Time0和Time1都经过设置,且Time0要大于Time1+4,那么我们发现题目合约中能够进行修改的所有函数都是经过了onlyWhitelisted
修饰过的,也就是说我们首先得获取到owner权限才能进行下一步操作
在进行进一步的做题之前,首先我们需要了解delegatecall的机制,不过有点麻烦,我这里也不是很想展开讲,各位可以自己搜一下资料了解delegatecall
这里获取owner权限运用的正是“保留上下文”,这里我们利用setZDriveOwner
函数对ZDriveContract进行delegatecall的时候,修改的并不是ZDriveContract的Owner,而是EkkoTimeRewind的Owner,也就是说我们只需要让传入的_ZDriveowner
是自己就行了,这样我们就在白名单里面了
接下来看setTime相关的操作,我们发现无论如何都只能进行1次设置时间的操作,而这就导致不管修改的是哪个Time,另一个Time就再也无法被修改了,无法满足题目条件,但是我们查看上面获取Owner的时候还能传入一个_Description
,说明我们还会修改EkkoTimeRewind的某一个参数,此时需要注意Solidity中的constant并不会保存在Storage中,也就是说这里的_Description
修改的其实是rewindBeforeTime
的值!因此我们在获取Owner的时候传入的_Description
实际上应该是我们的某个合约,这个合约要有一个setRewindBeforeTime
函数,这样我们通过setTime
进行delegatecall的时候就会直接调用到我们自己的合约的函数,这里给出一个简单的攻击合约的例子,目前已经在本地环境通过:(虽然题目私链为Istanbul分叉,我在Cancun分叉测试的,但是两个分叉期间delegatecall本身并没有太多改动)
1 | // exp.sol |
前面的参数完全照搬是为了和题目合约对齐,在合约部署完成之后调用attack即可,具体的和题目RPC交互的Web3py代码我就不写了
2025.8.24
饿了,来个三明治
三明治攻击(Sandwich Attack)一般可以分为2步:抢跑(Front-running)和尾随(Back-running),整个攻击一般是基于AMM(Automated Market Maker,自动化做市商)中对代币换算价格的恒定乘积公式x*y=k
,因此如果有人大量买入/售出某种代币,根据公式会对其它代币的价格产生剧烈波动
同时由于区块链的内存池(Mempool)机制,攻击者可以监测Mempool中的交易从而检测交易的盈利潜力
假如我们有个用户A使用代币X购入了大量的代币Y,那么可以想象代币Y的价格也会水涨船高,因此攻击者B可以通过在A之前购入代币Y,然后由此获利
B不可能预知A什么时候购入,但是B可以通过监测Mempool知道A什么时候发起了交易,然后在监测到的那一个瞬间购入代币Y,同时提出更高的gas费用,这样矿工在将交易打包进区块的时候会先打包B的购入交易,然后才是A的交易,这样B就“先于”A购入代币Y了,从而获利
怎么获利?在A购入后,B马上抛售手中的代币Y,当然gas费用要接近A购入的交易的gas费用,可能需要略高一点,从而将A的交易“夹”在中间,这就是为什么这类攻击被称为“三明治攻击”
当然,Front-running和Back-running远远不止这种情况,还有很多案例,这里不赘述,而且以上的攻击多出现在流动性低的资金池中,因此推荐多在流动性高的资金池中进行大额的交易
2025.8.26
马上开学了,重新测了一下7月份给26届新生出的题目,发现存在非预期解,所以稍微修改了一下源码和Docker镜像,但是由于新生赛还没有开赛,所以为了防止泄题,现在没法展开解释,只能说是和Anvil的--auto-impersonate
选项导致的任意地址使用有关,从而导致潜在的非预期解
修改方案也很简单,就是直接搬RemedyCTF的infra:在题目部署前后进行anvil_autoImpersonateAccount
的启用和禁用即可
2025.8.28
今天也在接着给26届新生出题,大方向是Web3的取证和溯源,不过因为新生赛还没有开赛暂时不能透露具体信息,不过可以简单写点思路
目前是在用Python写一个RAT(远控木马)(仅用于学习用途,不会发布到网络上也不会公布源码),攻击者通过该RAT窃取Chrome浏览器的历史记录、Cookie和钱包插件信息(当然,这个RAT的功能不止这些),然后攻击者通过窃取到的Metamask插件信息恢复出了钱包助记词,而且使用该钱包内的地址在公链(使用Sepolia Ethereum模拟真实生产环境)对一个存在漏洞的DeFi合约进行了攻击,在窃取了其资产后进行了资金的转移
目前对于DeFi合约的设计还在考虑中,计划是会使用到Chainlink VRF,同时可能会顺带写一个前端,看个人进度和心情(
预计是10月末或11月初对题目相关细节进行解禁,届时完整的Writeup会在本人博客(AuroraCTF2025 Misc 部分Writeup | 9C±Void’s Blog)公布,目前文章还是上锁的,不公开
2025.8.29
应该是最后一天了,拿RemedyCTF的题目结尾吧,这次选个难点的:Joe’s Lending Mirage
对着榜一wp看的,确实很复杂
整个题目是一个基于Trader Joe V2 Liquidity Book的借贷协议,存在健康因子(1.25)以让仓位维持125%抵押率,借贷因子(0.8)以保证用户只能借贷抵押品价值80%的资金,年利率5%等功能
整个存款、借贷和健康检查都相当健全,但是在JoeLending.sol
中,_update
函数在_reentrancyGuardEntered()
返回true的时候不执行健康检查,而这就是整道题目的漏洞点
我们知道在存款的时候会出发重入守卫,同时也会触发ERC1155的回调函数,因此这个时候我们就可以将抵押品转移给另一个合约,从而使用同一批抵押品进行二次甚至多次借贷,最终提走大量的USDJ
详细的实现就请自行看榜一wp吧,这里就不展开了
- 本文作者: 9C±Void
- 本文链接: https://cauliweak9.github.io/2025/08/04/DailyCheckin/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!