Uniswap V3 Clone 安全审计报告
合约代码写完跑通了,心里踏实了?别急,安全审计这关迟早得过。这次我用 Slither 对自己的 Uniswap V3 Clone 项目做了一轮静态分析,结果嘛……说实话,看到报告的那一刻还是有点冒冷汗的。
这篇记录整个排查和修复过程,给同样在写 DeFi 合约的朋友留个参考。
审计概况
跑完 Slither 之后,一共报了 62 个问题,其中高危的有 3 个。最要命的是一个叫 arbitrary-send-erc20 的漏洞,简单说就是攻击者可以伪造回调数据,把你的 token 从别的用户账户里转走**。
听起来很严重对吧?我们一个一个来看。
最危险的漏洞:arbitrary-send-erc20
问题出在哪
漏洞在 UniswapV3Manager 的两个回调函数里:uniswapV3MintCallback 和 uniswapV3SwapCallback。
问题出在这行代码:
IERC20(extraData.token0).transferFrom(extraData.player, msg.sender, amount0);
这里的 extraData.player 是从 bytes data 参数里解码出来的——而这个 data 参数是调用者随便传的。也就是说,攻击者可以把自己的地址换成受害者的地址,然后 Manager 就会尝试从受害者账户里转 token。
如果受害者恰好授权过攻击者(比如一些钓鱼场景),那 token 就真的会被转走。
攻击流程
整个过程大概是这样:
攻击者调用 Manager.mint(),传入伪造的 data:
↓
data 里的 player 字段被设成受害者地址
↓
Pool 回调 Manager.uniswapV3MintCallback()
↓
Manager 从受害者账户转账到 Pool(前提是受害者授权过攻击者)
↓
攻击者拿到流动性份额,受害者 token 被盗
Slither 的检测输出
Detector: arbitrary-send-erc20
UniswapV3Manager.uniswapV3MintCallback(uint256,uint256,bytes) uses arbitrary from in transferFrom:
IERC20(extraData.token0).transferFrom(extraData.player,msg.sender,amount0)
UniswapV3Manager.uniswapV3SwapCallback(int256,int256,bytes) uses arbitrary from in transferFrom:
IERC20(extraData.token1).transferFrom(extraData.player,msg.sender,uint256(amount1Delta))
Slither 直接指出了两行有问题的代码,很精准。
修复过程
思路
修这个漏洞需要解决两个问题:
- 确保回调的 Pool 是合法的——不是随便哪个合约都能调用 Manager 的回调函数
- 确保 player 是原始调用者——不能让调用者随便伪造一个地址
具体改动
改动了三个文件:
| 文件 | 改动 |
|---|---|
UniswapV3Pool.sol | 添加 fee 状态变量,修改 CallbackData 结构 |
UniswapV3Factory.sol | 创建 Pool 时传入 fee 参数 |
UniswapV3Manager.sol | 添加 Factory 依赖,加上验证逻辑 |
核心修复代码
Manager 里加了个 _checkPool 函数,用来验证调用者是不是 Factory 创建的正经 Pool:
function _checkPool(address pool) internal view {
address token0 = UniswapV3Pool(pool).token0();
address token1 = UniswapV3Pool(pool).token1();
uint24 fee = UniswapV3Pool(pool).fee();
address expectedPool = factory.getPool(token0, token1, fee);
require(pool == expectedPool, "Invalid pool");
}
然后在回调函数里加两层验证:
function uniswapV3MintCallback(
uint256 amount0,
uint256 amount1,
bytes calldata data
) external {
_checkPool(msg.sender); // 第一层:确认调用者是合法 Pool
UniswapV3Pool.CallbackData memory extraData = abi.decode(
data,
(UniswapV3Pool.CallbackData)
);
require(extraData.player == tx.origin, "Invalid player"); // 第二层:player 必须是交易原始发起者
IERC20(extraData.token0).transferFrom(extraData.player, msg.sender, amount0);
IERC20(extraData.token1).transferFrom(extraData.player, msg.sender, amount1);
}
swapCallback 也做了同样的处理。
为什么用 tx.origin
这里用了 tx.origin 来验证原始调用者。说实话,tx.origin 在 Solidity 社区争议挺大的,因为它不支持合约钱包(中间有任何合约调用的话 tx.origin 就不是最终调用者了)。
但在这个场景下反而是合适的——因为 Mint 和 Swap 操作本来就应该是由 EOA(普通用户钱包)直接发起的,中间不应该有其他合约代理。这样既防住了伪造 player 的攻击,也不会影响正常使用。
当然,如果后续要支持合约钱包,这个逻辑需要改成更完善的方案,比如通过 Manager 记录每次调用的 caller 映射。
安全验证流程
修完之后的验证链是这样的:
用户调用 Manager.mint()
↓
Manager 调用 Pool.mint()
↓
Pool 回调 Manager.uniswapV3MintCallback()
↓
┌─────────────────────────────────┐
│ _checkPool(msg.sender) │
│ 从 Pool 读取 token0/token1/fee│
│ 查 Factory.getPool() 对比地址 │
│ ✅ 防止恶意合约伪造回调 │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ require(player == tx.origin) │
│ 验证 player 是交易原始发起者 │
│ ✅ 防止伪造 player 地址 │
└─────────────────────────────────┘
↓
执行 transferFrom,安全 ✅
验证结果
编译和测试
改完后先跑编译:
$ forge build
Compiler run successful with warnings
然后跑测试:
$ forge test --summary
32 tests passed, 1 pre-existing assertion failure (unrelated to security fix)
之前通过的测试都还是通过的,说明修复没有破坏现有功能。
Slither 复检
最关键的一步——重新跑 Slither,确认漏洞消除了:
$ slither .
# arbitrary-send-erc20 不再出现在报告中 ✅
其他发现
除了这个高危漏洞,Slither 还报了一些其他问题,简单记录一下:
reentrancy-balance(重入攻击)
Slither 报了 mint() 函数的重入风险。这个其实是 Uniswap V3 原版的设计模式——通过比较回调前后的 balance 来检测重入,属于已知设计,不是 bug。
unchecked-transfer(未检查转账返回值)
UniswapV3Pool.swap() ignores return value by IERC20(token1).transfer(recipient, amountOut)
这个确实需要注意。有些 token 的 transfer 返回值是 false 表示失败,但代码里没检查。后续应该换成 safeTransfer 或者手动检查返回值。
总结
| 项目 | 修复前 | 修复后 |
|---|---|---|
| arbitrary-send-erc20 | ❌ 存在 | ✅ 已修复 |
| 编译 | - | ✅ 通过 |
| 测试 | - | ✅ 32/33 通过 |
几条经验教训
- 回调函数永远不要信任
bytes data里的地址——一定要验证来源 - Pool 合法性要验证——通过 Factory 反查是最靠谱的方式
- tx.origin 有局限但特定场景够用——如果未来支持合约钱包得换方案
- 定期跑 Slither——别等上线了才想起来审计,开发过程中就该定期扫
详细报告可以用 slither . --output slither-report.json 生成 JSON 版本。
安全是 DeFi 的生命线,宁可多花时间,也不能带病上线。