diff --git a/report.md b/report.md new file mode 100644 index 0000000..3f10d83 --- /dev/null +++ b/report.md @@ -0,0 +1,556 @@ +# SoDex Protocol 安全审计报告 V4 + +**审计日期**: 2026-01-16 +**审计范围**: `contracts` 目录下所有合约 + +--- + +## 执行摘要 + +### 发现统计 + +| 严重程度 | 数量 | 关键问题 | +| ------------------- | ---- | ------------------------------------------------------------------------------ | +| **致命 (Critical)** | 4 | staticcall 解析错误、计数器下溢资金锁定、Oracle 无验证、清算价格操纵 | +| **高危 (High)** | 3 | 清算精度、超额提取、nativeToken 无法提取 | +| **中危 (Medium)** | 4 | 白名单可绕过、Index Token 精度损失、RateLimiter 精度问题、VaultRedeem 缺少授权 | +| **低危 (Low)** | 10 | 拼写错误、事件索引、溢出风险等 | + +### 紧急建议 + +**立即暂停并修复**: + +1. Portal.mintToken 中的 staticcall 返回值解析错误 (C-01) +2. Caller 中的 pendingCallForCounts 下溢问题 (C-02) +3. Oracle 价格更新无验证 (C-03) +4. 清算函数存在原子价格操纵攻击向量 (C-04) + +### 风险提醒 + +1. **Admin 权限过大**: `DEFAULT_ADMIN_ROLE` 拥有几乎全部控制权(暂停、升级、资金提取等),需确认该角色是否由多签钱包持有 +2. **缺少时间锁机制**: 关键操作(如更换 Oracle、修改配置、提取资金)没有时间锁保护,恶意或被攻破的 admin 可立即执行危险操作 +3. **Bridge 资金强依赖项目方**: 用户锁定在 Bridge 合约中的资产完全依赖 `WITHDRAWER_ROLE` 处理提款请求,若项目方跑路或私钥丢失,用户资金将永久锁定且无法自行取出 + +--- + +## 目录 + +1. [致命漏洞 (Critical)](#一致命漏洞-critical) +2. [高危漏洞 (High)](#二高危漏洞-high) +3. [中危漏洞 (Medium)](#三中危漏洞-medium) +4. [低危漏洞 (Low)](#四低危漏洞-low) + +--- + +## 一、致命漏洞 (Critical) + +### C-01: Portal.mintToken 中 staticcall 返回值解析错误 + +**文件**: `SoDexTokenPortal.sol` 第 314-315 行 + +**问题代码**: + +```solidity +(bool tokenSupported, ) = clobGateway.staticcall(abi.encodeWithSelector(IClobGateway.getAssetId.selector, token)); +if (clobGateway != address(0) && toClob && tokenSupported) { +``` + +**问题分析**: + +`staticcall` 的返回值是 `(bool success, bytes memory returndata)`,当前代码将 `success` 误解为 `tokenSupported`: + +- 只要调用成功(函数没有 revert),`tokenSupported` 就是 `true` +- 即使 `getAssetId` 返回 `0`(表示不支持),条件仍会满足 +- 资金会被错误地发送到 ClobGateway + +**影响**: 资金丢失、协议不可用 + +**修复建议**: + +```solidity +bool tokenSupported = false; +if (clobGateway != address(0)) { + (bool success, bytes memory data) = clobGateway.staticcall( + abi.encodeWithSelector(IClobGateway.getAssetId.selector, token) + ); + if (success && data.length >= 32) { + uint256 assetId = abi.decode(data, (uint256)); + tokenSupported = assetId > 0; + } +} +if (clobGateway != address(0) && toClob && tokenSupported) { + // ... +} +``` + +--- + +### C-02: Caller 中 pendingCallForCounts 下溢导致资金永久锁定 + +**文件**: `SoDexTokenCaller.sol` 第 185-186 行 + +**问题代码**: + +```solidity +pendingCallForCounts[bridgeCallFor.chain][bridgeCallFor.inCoinAddr]--; +pendingCallForCounts[bridgeCallFor.chain][bridgeCallFor.outCoinAddr]--; +``` + +**对比 Withdrawer.sol 第 143-145 行(正确写法)**: + +```solidity +if (pendingWithdrawalCounts[withdrawal.chain][withdrawal.coinAddr] > 0) { + pendingWithdrawalCounts[withdrawal.chain][withdrawal.coinAddr]--; +} +``` + +**问题分析**: + +Caller 合约中的 `pendingCallForCounts` 直接递减,没有检查是否 > 0。在 Solidity 0.8+ 中,下溢会触发 revert,导致 CallFor 请求永远无法完成,用户资金永久锁定。 + +**影响**: 用户资金永久锁定 + +**修复建议**: + +```solidity +if (pendingCallForCounts[bridgeCallFor.chain][bridgeCallFor.inCoinAddr] > 0) { + pendingCallForCounts[bridgeCallFor.chain][bridgeCallFor.inCoinAddr]--; +} +if (pendingCallForCounts[bridgeCallFor.chain][bridgeCallFor.outCoinAddr] > 0) { + pendingCallForCounts[bridgeCallFor.chain][bridgeCallFor.outCoinAddr]--; +} +``` + +--- + +### C-03: Oracle 价格更新无任何验证 + +**文件**: `SoDexTokenOracle.sol` 第 152-160 行 + +**问题代码**: + +```solidity +function updateOraclePrices(bytes32[] calldata symbols) external onlyOperator { + uint256[] memory _prices = getOracleNumericValuesFromTxMsg(symbols); + for (uint256 i = 0; i < symbols.length; i++) { + // require(_symbols.contains(symbols[i]), "SoDexTokenOracle: symbol is not set"); // 被注释掉! + prices[symbols[i]] = _prices[i]; + updatedTimes[symbols[i]] = block.timestamp; + } + emit OraclePricesUpdated(symbols, _prices); +} +``` + +**缺失的安全检查**: + +1. **Symbol 验证被注释掉** - 可以为任意 symbol 设置价格 +2. **价格为 0 检查缺失** - 可以设置价格为 0 +3. **价格变化幅度限制缺失** - 可以从 $1 直接变为 $1,000,000 +4. **更新频率限制缺失** - 可以在同一区块多次更新 + +**影响**: 系统性资金损失,可能导致整个协议资金被盗 + +**修复建议**: + +```solidity +uint256 public constant MAX_PRICE_DEVIATION_PERCENT = 50; +uint256 public constant MIN_UPDATE_INTERVAL = 10; + +function updateOraclePrices(bytes32[] calldata symbols) external onlyOperator { + uint256[] memory _prices = getOracleNumericValuesFromTxMsg(symbols); + for (uint256 i = 0; i < symbols.length; i++) { + require(_symbols.contains(symbols[i]), "SoDexTokenOracle: symbol is not set"); + require(_prices[i] > 0, "SoDexTokenOracle: price cannot be zero"); + + uint256 oldPrice = prices[symbols[i]]; + if (oldPrice > 0) { + uint256 maxDelta = oldPrice * MAX_PRICE_DEVIATION_PERCENT / 100; + require( + _prices[i] >= oldPrice - maxDelta && _prices[i] <= oldPrice + maxDelta, + "SoDexTokenOracle: price deviation too large" + ); + } + + require( + block.timestamp >= updatedTimes[symbols[i]] + MIN_UPDATE_INTERVAL, + "SoDexTokenOracle: update too frequent" + ); + + prices[symbols[i]] = _prices[i]; + updatedTimes[symbols[i]] = block.timestamp; + } + emit OraclePricesUpdated(symbols, _prices); +} +``` + +--- + +### C-04: 清算函数存在原子价格操纵攻击向量 + +**文件**: `SoDexTokenCreditor.sol` 第 244-264 行 + +**问题分析**: + +清算函数 `_liquidateCreditMint` 在同一交易中读取 Oracle 价格并执行清算,如果 Oracle 价格可以在同一区块内被更新,攻击者可以操纵价格后立即清算健康仓位。当前 Oracle 只检查价格是否"过期",但没有检查价格是否"太新"。 + +**影响**: 健康仓位被恶意清算 + +**修复建议**: + +```solidity +uint256 public constant MIN_PRICE_AGE = 30; // 价格至少存在 30 秒 + +function _liquidateCreditMint(...) internal { + bytes32 repaySymbol = ISoDexTokenOracle(oracle).tokenSymbols(repayToken); + bytes32 creditSymbol = ISoDexTokenOracle(oracle).tokenSymbols(creditToken); + + require( + block.timestamp >= ISoDexTokenOracle(oracle).updatedTimes(repaySymbol) + MIN_PRICE_AGE, + "SoDexTokenCreditor: repay token price too fresh" + ); + require( + block.timestamp >= ISoDexTokenOracle(oracle).updatedTimes(creditSymbol) + MIN_PRICE_AGE, + "SoDexTokenCreditor: credit token price too fresh" + ); + // ... existing liquidation logic +} +``` + +--- + +## 二、高危漏洞 (High) + +### H-01: Creditor 清算精度损失可能导致坏账 + +**文件**: `SoDexTokenCreditor.sol` 第 257-258 行 + +**问题代码**: + +```solidity +uint256 creditAmount = repayTokenPrice * repayAmount * 10 ** ISoDexToken(creditToken).decimals() + / (creditTokenPrice * 10 ** ISoDexToken(repayToken).decimals()); +creditAmount += creditAmount * creditSettings[creditToken].liquidationPenalty / 100; +``` + +**问题分析**: + +1. **乘法在除法之前**:可能导致中间结果溢出 +2. **精度损失在大额交易时显著**:先乘后除导致向下取整,清算者可能获得更少的抵押品,协议可能产生坏账 + +**修复建议**: + +```solidity +uint256 creditAmount = Math.mulDiv( + repayTokenPrice * repayAmount, + 10 ** ISoDexToken(creditToken).decimals(), + creditTokenPrice * 10 ** ISoDexToken(repayToken).decimals(), + Math.Rounding.Ceil +); +``` + +--- + +### H-02: Custody withdrawNativeToken 可以超额提取 + +**文件**: `SoDexTokenCustody.sol` 第 168-172 行 + +**问题代码**: + +```solidity +function withdrawNativeToken(uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(address(this).balance >= amount, "SoDexTokenCustody: native token amount not enough"); + (bool success,) = msg.sender.call{value: amount}(""); + require(success, "SoDexTokenCustody: Failed to withdraw native token"); +} +``` + +**问题分析**: + +- 只检查 `address(this).balance >= amount` +- 没有检查 `custodyAmount + bridgedAmount` 的限制 +- 管理员可以提取超过应有的金额,导致用户无法提款 + +**修复建议**: + +```solidity +function withdrawNativeToken(uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 userFunds = 0; + for (uint256 i = 0; i < remoteNativeTokenChainSet.length(); i++) { + bytes32 chainId = remoteNativeTokenChainSet.at(i); + userFunds += remoteNativeTokens[chainId].custodyAmount; + userFunds += remoteNativeTokens[chainId].bridgedAmount; + } + + uint256 withdrawable = address(this).balance > userFunds ? + address(this).balance - userFunds : 0; + require(amount <= withdrawable, "SoDexTokenCustody: would affect user funds"); + + (bool success,) = msg.sender.call{value: amount}(""); + require(success, "SoDexTokenCustody: Failed to withdraw native token"); +} +``` + +--- + +### H-03: Bridge 合约缺少 nativeToken 的 withdraw 功能 + +**文件**: `SoDexTokenBridge.sol` + +**问题描述**: + +Bridge 合约中 `nativeToken` 存入后无法正常提取: + +- `bridgeNativeToken()` 会增加 `nativeBridgedAmount` +- `withdraw()` 函数要求 `bridgeTokens` 存在,但 nativeToken 不在 bridgeTokens 中 +- `setNativeToken()` 明确禁止 nativeToken 成为 bridgeToken +- **没有任何函数可以减少 `nativeBridgedAmount` 并将代币返还用户** + +**影响**: 用户通过 `bridgeNativeToken()` 存入的代币**永久锁定** + +**修复建议**: + +```solidity +function withdrawNativeToken( + uint256 withdrawId, + address receiver, + uint256 amount, + uint256 withdrawFee +) external onlyRole(WITHDRAWER_ROLE) whenNotPaused { + require(!usedWithdrawIds[withdrawId], "SoDexTokenBridge: withdraw id used"); + require(nativeToken != address(0), "SoDexTokenBridge: native token not set"); + require(nativeBridgedAmount >= amount, "SoDexTokenBridge: bridged amount not enough"); + require(amount > withdrawFee, "SoDexTokenBridge: amount must be greater than withdraw fee"); + require(receiver != address(0), "SoDexTokenBridge: receiver cannot be zero"); + + usedWithdrawIds[withdrawId] = true; + nativeBridgedAmount -= amount - withdrawFee; + + IERC20(nativeToken).safeTransfer(receiver, amount - withdrawFee); + emit BridgeWithdraw(withdrawId, IERC20Metadata(nativeToken).symbol(), nativeToken, receiver, amount, withdrawFee); +} +``` + +--- + +## 三、中危漏洞 (Medium) + +### M-01: Withdrawer 白名单可即时禁用 + +**文件**: `SoDexTokenWithdrawer.sol` 第 163-166 行 + +```solidity +function disableWhitelist() external whenNotPaused { + enableWhitelists[msg.sender] = false; // 立即生效! + emit WhitelistDisabled(msg.sender); +} +``` + +**风险**: 如果用户私钥被盗,攻击者可以立即禁用白名单并提款到任意地址。 + +**建议**: 添加 24-48 小时延迟。 + +--- + +### M-02: Bridge nativeToken 可在有余额时被更换 + +**文件**: `SoDexTokenBridge.sol` 第 110-117 行 + +```solidity +function setNativeToken(address _nativeToken) external onlyOwner { + require(_nativeToken != address(0), "..."); + require(nativeToken != _nativeToken, "..."); + // 没有检查 nativeBridgedAmount == 0! + emit NativeTokenSet(nativeToken, _nativeToken); + nativeToken = _nativeToken; +} +``` + +**风险**: 如果更换 nativeToken,旧 token 的余额可能被锁定,会计混乱。 + +**建议**: 添加 `require(nativeBridgedAmount == 0, "...")` + +--- + +### M-03: Index Token 价格计算精度损失累积 + +**文件**: `SoDexTokenOracle.sol` 第 179-183 行 + +```solidity +for (uint256 i = 0; i < tokenUnderlyingSymbols[token].length; i++) { + SubSymbol memory subSymbol = tokenUnderlyingSymbols[token][i]; + price += getPriceBySymbol(subSymbol.symbol) * subSymbol.amount / 10 ** subSymbol.decimals; + // 每次循环都有一次除法,精度损失会累积 +} +``` + +--- + +### M-04: RateLimiter 整数除法精度问题 + +**文件**: `RateLimiter.sol` 第 27 行 + +```solidity +return Math.min(rateLimit.cap, rateLimit.lastBufferLeft + + Math.min(timeSinceLastBuffer, rateLimit.timeWindow) * rateLimit.cap / rateLimit.timeWindow); +``` + +**风险**: 当 `timeSinceLastBuffer` 很小时,`timeSinceLastBuffer * cap / timeWindow` 可能为 0。 + +--- + +### M-05: VaultRedeemWithPermit 缺少 forceApprove + +**文件**: `SoDexTokenCallForPermit.sol` 第 146-151 行 + +**问题代码**: + +```solidity +// VaultDepositWithPermit 有 forceApprove +} else if (Strings.equal(cmdType, "VaultDepositWithPermit")) { + IERC20(token).safeTransferFrom(account, address(this), amount); + IERC20(token).forceApprove(to, amount); // 有授权 + IVault(to).deposit(amount, account); +} + +// VaultRedeemWithPermit 没有 forceApprove! +} else if (Strings.equal(cmdType, "VaultRedeemWithPermit")) { + IERC20(token).safeTransferFrom(account, address(this), amount); + // 缺少 forceApprove! + IVault(to).redeem(amount, account); +} +``` + +**影响**: 如果 Vault 实现需要从 `msg.sender` 转移代币,`redeem` 操作会失败。 + +**修复建议**: + +```solidity +} else if (Strings.equal(cmdType, "VaultRedeemWithPermit")) { + IERC20(token).safeTransferFrom(account, address(this), amount); + IERC20(token).forceApprove(to, amount); // 添加授权 + IVault(to).redeem(amount, account); +} +``` + +--- + +### M-06: 清算时 creditAmount 可能超过被清算者的实际抵押品 + +**文件**: `SoDexTokenCreditor.sol` 第 257-260 行 + +**问题代码**: + +```solidity +uint256 creditAmount = repayTokenPrice * repayAmount * ... ; +creditAmount += creditAmount * liquidationPenalty / 100; + +_descreseCreditAmount(account, creditToken, creditAmount); +// 要求: creditAmounts[account][creditToken] >= creditAmount +``` + +**问题分析**: + +如果计算出的 `creditAmount`(含清算罚款)大于被清算者的实际抵押品,`_descreseCreditAmount` 会 revert,导致**坏账无法处理**。 + +**修复建议**: + +```solidity +function _liquidateCreditMint(...) internal { + uint256 creditAmount = repayTokenPrice * repayAmount * ... ; + creditAmount += creditAmount * liquidationPenalty / 100; + + uint256 availableCredit = creditAmounts[account][creditToken]; + if (creditAmount > availableCredit) { + creditAmount = availableCredit; // 只清算可用的部分 + } + + _descreseCreditMintAmount(account, repayToken, repayAmount); + _descreseCreditAmount(account, creditToken, creditAmount); + // ... +} +``` + +--- + +## 四、低危漏洞 (Low) + +### L-01: 多处拼写错误 + +| 文件 | 位置 | 错误 | 正确 | +| ------------------------ | --------- | ----------------------- | ----------------------- | +| SoDexTokenPortal.sol | 第 36 行 | `depricated` | `deprecated` | +| BridgeAccountFactory.sol | 第 82 行 | `getOrCreateAccout` | `getOrCreateAccount` | +| SoDexTokenCreditor.sol | 第 164 行 | `_descreseCreditAmount` | `_decreaseCreditAmount` | + +### L-02: 事件缺少 indexed 参数 + +**示例**: `SoDexTokenPortal.sol` 第 53-55 行 + +```solidity +event MintToken(string coinSymbol, address token, string chain, string coinAddr, address account, ...); +// 建议: address indexed token, address indexed account +``` + +### L-03: 未使用 Custom Errors(Gas 浪费) + +所有合约使用 `require(condition, "error message")` 形式,建议使用 Solidity 0.8.4+ 的 custom errors。 + +### L-04: Magic Numbers + +多处硬编码数字如 `100`, `20`, `8`,应使用命名常量。 + +### L-05: 缺少 NatSpec 文档 + +大部分函数缺少 NatSpec 注释。 + +### L-06: 多处字符串比较使用 keccak256(Gas 浪费) + +```solidity +// 当前 +if (keccak256(abi.encodePacked(transaction.bridgeAddr)) != keccak256(abi.encodePacked(""))) { + +// 建议 +if (bytes(transaction.bridgeAddr).length > 0) { +``` + +### L-07: Memo 字段无长度限制 + +**文件**: `SoDexTokenWithdrawer.sol` + +`memo` 参数没有长度限制,恶意用户可以传入超大字符串增加存储成本。 + +### L-08: 缺少 receive() 函数 + +某些应该接收 ETH 的合约缺少 `receive()` 函数。 + +### L-09: Oracle.convert 乘法可能溢出 + +**文件**: `SoDexTokenOracle.sol` 第 199 行 + +```solidity +function convert(uint256 amount0, address token0, address token1) external view returns (uint256 amount1) { + amount1 = (amount0 * getPrice(token0)) * 10 ** IERC20Metadata(token1).decimals() / ...; + // amount0 * price * 10^decimals 可能溢出 +} +``` + +**修复建议**: 使用 `Math.mulDiv` 进行安全计算。 + +### L-10: Creditor.\_sum 中乘法可能溢出 + +**文件**: `SoDexTokenCreditor.sol` 第 117 行 + +```solidity +function _sum(...) internal view returns (uint256 total) { + for (uint256 i = 0; i < tokens.length; i++) { + uint256 price = ISoDexTokenOracle(oracle).getPrice(tokens[i]); + total += Math.mulDiv(amounts[i] * ratios[i], price, ...); + // amounts[i] * ratios[i] 在传给 Math.mulDiv 之前可能先溢出 + } +} +``` + +--- + +_报告版本: 4.0_ +_审计日期: 2026-01-16_