Tyojong
[Solodit] swapTokens does not account for SwapX router fees when the out token is native 본문
[Solodit] swapTokens does not account for SwapX router fees when the out token is native
Tyojong 2025. 9. 22. 16:25개요
심각도 : Medium
언어 : Solidity
취약점 유형 : Logic Bug
이 보고서는 Funnel 프로젝트에서 Pashov Audit Group이 발견한 swapTokens 함수의 토큰 스왑 시, out 토큰이 네이티브(예: ETH)일 때 SwapX 라우터의 수수료를 적절히 처리하지 않는 취약점에 대해 설명한다.
swapTokens 함수가 SwapX 라우터를 통해 토큰을 스왑할 때, out 토큰이 네이티브(예: ETH)라면 SwapX 라우터는 실제로 사용자의 수신 금액에서 수수료를 공제(fee)한다.
하지만 swapTokens 함수(및 FunnelVault)는 swap 결과로 반환받는 out amount(토큰 수량)가 수수료 제하기 전의 값이라고 잘못 가정한다.
실제로 사용자 지갑에는 수수료가 제해진 만큼만 들어오지만, 내부 로직상엔 수수료 제하기 전 토큰 수량을 받았다고 기록된다.
영향 받는 코드
takeFee 함수
function takeFee(address tokenIn, uint256 amountIn) internal returns (uint256){
if (feeExcludeList[msg.sender])
return 0;
uint256 fee = amountIn.mul(feeRate).div(feeDenominator);
if ( tokenIn == address(0) || tokenIn == WETH ) {
require(address(this).balance > fee, "insufficient funds");
(bool success, ) = address(feeCollector).call{ value: fee }("");
require(success, "SwapX: take fee error");
} else
IERC20Upgradeable(tokenIn).safeTransferFrom(msg.sender, feeCollector, fee);
emit FeeCollected(tokenIn, msg.sender, fee, amountIn, block.timestamp);
return fee;
}
스왑에서 네이티브 토큰인 경우, 실제 전송 전 수수료를 떼어 feeCollector에게 지급하고, 반환값은 단순히 수수료 값을 나타낸다.
swapV2ExactIn
function swapV2ExactIn(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 amountOutMin,
address poolAddress
) payable public nonReentrant whenNotPaused returns (uint amountOut){
// ...
bool nativeOut = false;
if (tokenOut == address(0))
nativeOut = true; <@
// ...
if (nativeOut) {
amountOut = IERC20Upgradeable(WETH).balanceOf(address(this)).sub(balanceBefore); <@
IWETH(WETH).withdraw(amountOut);
uint256 fee = takeFee(address(0), amountOut); <@
(bool success, ) = address(msg.sender).call{value: amountOut-fee}(""); <@
require(success, "SwapX: send ETH out error");
} else {
amountOut = IERC20Upgradeable(tokenOut).balanceOf(msg.sender).sub(balanceBefore);
}
require(
amountOut >= amountOutMin,
'SwapX: insufficient output amount'
);
}
swapV3ExactIn
function swapV3ExactIn (
ExactInputSingleParams memory params
) external payable nonReentrant whenNotPaused checkDeadline(params.deadline) returns (uint256 amountOut) {
// ...
bool nativeOut = false;
if (params.tokenOut == WETH)
nativeOut = true;
// ...
amountOut = exactInputInternal( <@
params.amountIn,
nativeOut ? address(0) : params.recipient,
params.sqrtPriceLimitX96,
SwapCallbackData({path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut), payer: msg.sender, payerOrigin: msg.sender})
);
require(amountOut >= params.amountOutMinimum, "SwapX: insufficient out amount");
if (nativeOut) {
IWETH(WETH).withdraw(amountOut);
uint256 fee = takeFee(address(0), amountOut); <@
(bool success, ) = address(params.recipient).call{value: amountOut-fee}(""); <@
require(success, "SwapX: send ETH out error");
}
}
네이티브 토큰으로 스왑할 경우, 실제로 전송되는 금액에서 takeFee로 수수료를 공제한다. 반환값 amountOut은 수수료 차감 이전 액수라서 실제 사용자 수령 액수와 다르다.
FunnelVault의 swapTokens 함수
function swapTokens(SwapParams calldata swapParams, address swapRouter, bool isBurn) external onlyRole(EXECUTER_ROLE) nonReentrant returns (uint256 amountOut) {
// ...
if (swapParams.useV3) {
ISwapX.ExactInputSingleParams memory params = ISwapX.ExactInputSingleParams({
tokenIn: swapParams.tokenIn,
tokenOut: swapParams.tokenOut,
fee: swapParams.fee,
recipient: address(this),
deadline: swapParams.deadline,
amountIn: swapParams.amountIn,
amountOutMinimum: swapParams.amountOutMin,
sqrtPriceLimitX96: 0
});
if (params.tokenIn == address(0)) {
amountOut = ISwapX(swapRouter).swapV3ExactIn{value: params.amountIn}(params);
} else {
amountOut = ISwapX(swapRouter).swapV3ExactIn(params); <@
}
} else {
if (swapParams.poolAddress == address(0)) {
revert ZeroAddress();
}
if (swapParams.tokenIn == address(0)) {
amountOut = ISwapX(swapRouter).swapV2ExactIn{value: swapParams.amountIn}(
swapParams.tokenIn,
swapParams.tokenOut,
swapParams.amountIn,
swapParams.amountOutMin,
swapParams.poolAddress
);
} else {
amountOut = ISwapX(swapRouter).swapV2ExactIn( <@
swapParams.tokenIn,
swapParams.tokenOut,
swapParams.amountIn,
swapParams.amountOutMin,
swapParams.poolAddress
);
}
}
if (amountOut == 0) {
revert SwapFailed();
}
if (swapParams.tokenOut == WETH && swapParams.useV3) {
if(isBurn){
addToPoolBurn(address(0), swapParams.payingPool, amountOut); <@
} else {
addToPoolHIP(address(0), swapParams.payingPool, amountOut); <@
}
} else {
if(isBurn){
addToPoolBurn(swapParams.tokenOut, swapParams.payingPool, amountOut); <@
} else {
addToPoolHIP(swapParams.tokenOut, swapParams.payingPool, amountOut); <@
}
}
emit TokenSwapped(swapParams.tokenIn, swapParams.tokenOut, swapParams.amountIn, amountOut, swapRouter);
}
FunnelVault는 SwapX 라우터의 반환값(amountOut)이 실제 금액이라고 신뢰하여, 실제 자신이 수령한 잔고(before/after)를 비교하지 않고, 반환값으로 풀에 넣을 값·회계 관리를 진행한다. 수수료가 차감된 실제 입금액보다 많이 받은 것 처럼 내역에 기록되고 회계 오류, 경로상 토큰 mismatch가 발생 가능하다.
레퍼런스
Smart Contract Vulnerability Dataset - Cyfrin Solodit
h-02-swaptokens-does-not-account-for-swapx-router-fees-when-the-out-token-is-native-pashov-audit-group-none-funnel_2025-08-27-markdown
solodit.cyfrin.io
audits/team/md/Funnel-security-review_2025-08-27.md at master · pashov/audits
Contribute to pashov/audits development by creating an account on GitHub.
github.com