Balancer 漏洞分析
对于使用恒定乘积公式的AMM的价格操纵获利,一种常见的方法是使得交易池币对中有一方数量失衡最终导致价格失衡,用极少量的代币A即可兑换出大量代币B。 通常具体的攻击步骤为:
根据以上攻击思路,从交易记录分析攻击过程:
swapExactAmountIn 函数的业务逻辑见代码注释:
function swapExactAmountIn(
address tokenIn,
uint tokenAmountIn,
address tokenOut,
uint minAmountOut,
uint maxPrice
)
external
_logs_
_lock_
returns (uint tokenAmountOut, uint spotPriceAfter)
{
require(_records[tokenIn].bound, "ERR_NOT_BOUND");
require(_records[tokenOut].bound, "ERR_NOT_BOUND");
require(_publicSwap, "ERR_SWAP_NOT_PUBLIC");
//1、获取转帐前的两种代币数量记录
Record storage inRecord = _records[address(tokenIn)];
Record storage outRecord = _records[address(tokenOut)];
require(tokenAmountIn <= bmul(inRecord.balance, MAX_IN_RATIO), "ERR_MAX_IN_RATIO");
//2、获取交易前的价格
uint spotPriceBefore = calcSpotPrice(
inRecord.balance,
inRecord.denorm,
outRecord.balance,
outRecord.denorm,
_swapFee
);
//3、判断交易价格是否小于用户设置的maxPrice,用于防止三明治攻击,即当价格滑点大于用户的预期最大可接受值,将终止交易
require(spotPriceBefore <= maxPrice, "ERR_BAD_LIMIT_PRICE");
tokenAmountOut = calcOutGivenIn(
inRecord.balance,
inRecord.denorm,
outRecord.balance,
outRecord.denorm,
tokenAmountIn,
_swapFee
);
//4、此处判断兑换出的代币数量是否大于用户的设置的minAmountOut,可用于防止三明治攻击
require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");
//5、此处是漏洞利用关键,BPT 合约直接将用户的输入代币数量记录在总代币数量中
inRecord.balance = badd(inRecord.balance, tokenAmountIn);
outRecord.balance = bsub(outRecord.balance, tokenAmountOut);
//6、BPT 合约在完成转账后重新计算价格,校验价格是否在预期范围内,防止价格被操纵
spotPriceAfter = calcSpotPrice(
inRecord.balance,
inRecord.denorm,
outRecord.balance,
outRecord.denorm,
_swapFee
);
require(spotPriceAfter >= spotPriceBefore, "ERR_MATH_APPROX");
require(spotPriceAfter <= maxPrice, "ERR_LIMIT_PRICE");
require(spotPriceBefore <= bdiv(tokenAmountIn, tokenAmountOut), "ERR_MATH_APPROX");
emit LOG_SWAP(msg.sender, tokenIn, tokenOut, tokenAmountIn, tokenAmountOut);
//7、调用 STA、WETH的transferFrom函数转移代币
_pullUnderlying(tokenIn, msg.sender, tokenAmountIn);
_pushUnderlying(tokenOut, msg.sender, tokenAmountOut);
return (tokenAmountOut, spotPriceAfter);
}
查看_pullUnderlying函数的逻辑,发现其代码逻辑是直接使用相关代币的transferFrom函数实现代币转移,此处tokenIn 指的是STA 代币:
function _pullUnderlying(address erc20, address from, uint amount)
internal
{
bool xfer = IERC20(erc20).transferFrom(from, address(this), amount);
require(xfer, "ERR_ERC20_FALSE");
}
继续跟进 STA 代币的transferFrom函数:
function transferFrom(address from, address to, uint256 value) public returns (bool) {
require(value <= _balances[from]);
require(value <= _allowed[from][msg.sender]);
require(to != address(0));
_balances[from] = _balances[from].sub(value);
//因为STA是通缩型代币,每次转移代币将由收款人承担一定的费用,数量为tokensToBurn = cut(value);扣除费用后再将代币转移给接收人
uint256 tokensToBurn = cut(value);
uint256 tokensToTransfer = value.sub(tokensToBurn);
_balances[to] = _balances[to].add(tokensToTransfer);
_totalSupply = _totalSupply.sub(tokensToBurn);
_allowed[from][msg.sender] = _allowed[from][msg.sender].sub(value);
emit Transfer(from, to, tokensToTransfer);
emit Transfer(from, address(0), tokensToBurn);
return true;
}
CUT函数实现逻辑:
function cut(uint256 value) public view returns (uint256) {
uint256 roundValue = value.ceil(basePercent);
uint256 cutValue = roundValue.mul(basePercent).div(10000);
return cutValue;
}
CUT函数属性为view,直接通过合约查看输入1单位的STA代币将通缩多少,显示结果为1,意味着用户转账的1 STA代币全部燃烧,BPT合约最终没有接收到STA代币: https://etherscan.io/token/0xa7de087329bfcda5639247f96140f9dabe3deed1#readContract
还剩下一个问题,如果攻击者继续转入STA代币,但BPT合约直接使用合约本地记录的代币数量计算价格,还是无法达到控制价格的目的,此时该使用另一个关键函数gulp :
Record storage inRecord = _records[address(tokenIn)];
Record storage outRecord = _records[address(tokenOut)];
在gulp函数中,会使用balanceOf函数重新覆盖BPT记录的值,且这个函数gulp 的可见性为external ,意味着任何人都可调用。根据之前的分析,在STA代币中记录BPT的余额为1,此时会使用1覆盖STA的本地记录,最终使得在BPT交易所中STA/WETH的价格始终保持高位,实现套利。
function gulp(address token)
external
_logs_
_lock_
{
require(_records[token].bound, "ERR_NOT_BOUND");
_records[token].balance = IERC20(token).balanceOf(address(this));
}
至此,整个攻击逻辑分析完毕。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!