接下来我们就 swapExactAmountIn 这个函数对代码展开分析,代码如下:function swapExactAmountIn(address tokenIn,uint tokenAmoun
前言
2020 年 6 月 28 日,自动化做市商服务提供者 Balancer 遭受攻击,慢雾安全团队在收到情报后对本次攻击事件进行了全面的分析,下面就这次攻击事件,为大家展开具体的技术分析。
知识储备
自动做市商服务(AMM)
Balancor 是一个提供 AMM 服务的合约,也就是自动化做市商服务,自动化做市商服务提供者采用代币池中的各种代币之间的数量的比例确定代币之间的价格,用户可通过这种代币之间的动态比例获取代币之间的价格,进而在合约中进行代币之间的兑换。
通缩型代币
通缩代币模型是随着时间的推移从市场上减少代币的一种模型。可以通过多种方法将代币从市场上减少,包括代币回购和代币创建者进行的代币销毁。本次攻击的主角-STA 代币就是一款通缩型代币,它是通过在转账的时候燃烧转账用户的余额实现代币的通缩。主要的实现代码如下(以 transfer 为例):
function transfer(address to, uint256 value) public returns (bool) {
require(value <= _balances[msg.sender]);
require(to != address(0));
// 代币通缩逻辑
uint256 tokensToBurn = cut(value);
uint256 tokensToTransfer = value.sub(tokensToBurn);
_balances[msg.sender] = _balances[msg.sender].sub(value);
_balances[to] = _balances[to].add(tokensToTransfer);
// 进行代币通缩
_totalSupply = _totalSupply.sub(tokensToBurn);
emit Transfer(msg.sender, to, tokensToTransfer);
emit Transfer(msg.sender, address(0), tokensToBurn);
return true;
}
了解以上两点后,我们就可以开始进行详细的技术分析。
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/89cc1031afdb5ce.jpg)
技术细节
本次攻击的交易如下:https://etherscan.io/tx/0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106
通过代币转移概览,我们可以看到,攻击者(0x81d)多次向 Balancer 合约(0x0e5)发送 WETH 兑换 STA 代币。
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/89cc1031afdb5ce.jpg)
为了知道更加具体的交易细节,我们使用 OKO 合约浏览工具对交易进行分析:https://oko.palkeo.com/0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106/
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/b6f401598fab68c.jpg)
通过分析交易内具体的细节,可以发现攻击者频繁调用了 swapExactAmountIn这个函数(上图列出的只是一部分,读者可自行通过连接访问查看具体的结果)。接下来我们就 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");
// 获取兑换时转入代币和要转出的代币的余额
Record storage inRecord = _records[address(tokenIn)];
Record storage outRecord = _records[address(tokenOut)];
require(tokenAmountIn <= bmul(inRecord.balance, MAX_IN_RATIO), "ERR_MAX_IN_RATIO");
uint spotPriceBefore = calcSpotPrice(
inRecord.balance,
inRecord.denorm,
outRecord.balance,
outRecord.denorm,
_swapFee
);
require(spotPriceBefore <= maxPrice, "ERR_BAD_LIMIT_PRICE");
// 计算兑换代币的数额
tokenAmountOut = calcOutGivenIn(
inRecord.balance,
inRecord.denorm,
outRecord.balance,
outRecord.denorm,
tokenAmountIn,
_swapFee
);
require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");
// 更新转入和转出代币的余额
inRecord.balance = badd(inRecord.balance, tokenAmountIn);
outRecord.balance = bsub(outRecord.balance, tokenAmountOut);
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");
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/64f91a6de9f4690.jpg)
// 拉取用户用于兑换的代币和将用户要兑换的代币推送给用户
_pullUnderlying(tokenIn, msg.sender, tokenAmountIn);
_pushUnderlying(tokenOut, msg.sender, tokenAmountOut);
return (tokenAmountOut, spotPriceAfter);
}
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/1810ae514ba4d93.jpg)
通过分析 swapExactAmountIn 函数,可以知道函数的主要的流程如下:
1、获取进行兑换的两种代币的余额
2、根据代币的余额计算价格,检查交易前价格是否合理
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/64f91a6de9f4690.jpg)
4、更新进行兑换的两种代币的余额
5、计算兑换后的价格,并检查价格是否合理
6、拉取用户用于兑换的代币,并将用户需要兑换的目标代币转给用户
通过分析该函数,我们并未发现太多异常,这是一个正常的兑换流程,但需要注意的是这里获取两个代币余额的方式不是通过 balanceOf 的方式,而是用存储于 _records 变量中的 balance 的值来获取指定代币的余额,理解这点有利于分析攻击者接下来的操作。既然这是个正常的流程, 那为什么攻击者需要频繁调用这个函数呢?这里就要引入这次的主角 - 通缩型代币 STA。
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/64f91a6de9f4690.jpg)
在调用了24次 swapExactAmountIn 函数后,攻击者已经将 Balancer 中的 STA 数量控制在一个低点,此时 STA 兑 WETH 的价格已经很高了,这时候攻击者开始使用 swapExactAmountIn 函数,使用 STA 兑换 WETH。
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/1810ae514ba4d93.jpg)
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/64f91a6de9f4690.jpg)
按照正常的流程,即使现在 STA 兑换 WETH 的价格已经很高,但是由于攻击者在使用 WETH 兑换 STA 的时候,由于燃烧机制,攻击者是没有拿到用于兑换的 WETH 等额价值的 STA,所以即使攻击者使用兑换出来的全部 STA 去兑换 WETH,由于兑换过程中会导致 Balancer 中的 STA 余额变大,导致 STA 兑 WETH 的价格降低,攻击者最终还是亏的,攻击者之所以能在 STA 换 WETH 的过程中获利,原因在于交易链中调用的 gulp 函数,函数的代码具体如下:
function gulp(address token)
external
_logs_
_lock_
{
require(_records[token].bound, "ERR_NOT_BOUND");
_records[token].balance = IERC20(token).balanceOf(address(this));
}
可以看到,gulp 函数主要是对 _records 变量中的 balance 进行修正。从上文可以知道,_records 中存储的是对应币种的余额信息,那么调用 gulp 函数,实际上就是对相应的代币的余额进行修正。那么攻击者为什么要调用这个函数呢?通过观察上图调用链中攻击者在调用 swapExactAmountIn 传入的 STA 的数量,可以发现传入的数量为1,那么根据 STA 的燃烧机制,在转账过程中,攻击者实际上没有向 Balancer 合约进行 STA 转账。
![慢雾:Balancer 第一次被黑详细分析](https://www.idoola.cn/uploadfile/202305/1810ae514ba4d93.jpg)
转账的 1 STA 在转账的过程中燃烧了。紧接着我们再次回顾 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");
Record storage inRecord = _records[address(tokenIn)];
Record storage outRecord = _records[address(tokenOut)];
require(tokenAmountIn <= bmul(inRecord.balance, MAX_IN_RATIO), "ERR_MAX_IN_RATIO");
uint spotPriceBefore = calcSpotPrice(
inRecord.balance,
inRecord.denorm,
outRecord.balance,
outRecord.denorm,
_swapFee
);
require(spotPriceBefore <= maxPrice, "ERR_BAD_LIMIT_PRICE");
// 计算兑换的代币数量,即使没有收到 STA,此处依然进行了计算
tokenAmountOut = calcOutGivenIn(
inRecord.balance,
inRecord.denorm,
outRecord.balance,
outRecord.denorm,
tokenAmountIn,
_swapFee
);
require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");
// 更新转入和转出代币的余额
inRecord.balance = badd(inRecord.balance, tokenAmountIn);
outRecord.balance = bsub(outRecord.balance, tokenAmountOut);
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);
// 拉取用户用于兑换的代币和将用户要兑换的代币推送给用户
_pullUnderlying(tokenIn, msg.sender, tokenAmountIn);
_pushUnderlying(tokenOut, msg.sender, tokenAmountOut);
return (tokenAmountOut, spotPriceAfter);
}
虽然 Balancer 合约没有收到 STA,但是由于兑换数量是直接取用户传入的值,所以即使没有向 Balancer 转 STA,由于 STA 兑换 WETH 价格很高,依然能换出大量的 WETH。这里还有个问题,虽然 Balancer 合约没有收到 STA,但是相关的入账却记录在了 _records 中(42行)。这会导致一个问题,随着兑换次数的增加,Balancer 池子中 STA 的记录值会不断增加,STA 兑换 WETH 的价格会逐步降低,这样下去是无法持续获利的,如何每次都维持最低的价格进行兑换呢?答案就在gulp 函数中。
由于攻击者在使用 STA 兑换 WETH 的时候,由于燃烧的原因,Balancer 合约实际并没有收到 STA,导致虽然 swapExactAmountIn 使用 _records 记录了相应的入账,但是 Balancer 合约的 STA 代币的真实余额并没有发生变化。当调用 gulp 函数的时候,由于 gulp 函数获取到的是合约真正持有的 token balance,会导致覆盖先前调用 swapExactAmountIn 函数时执行 inRecord.balance = badd(inRecord.balance, tokenAmountIn) 的值,那么在下次兑换的时候,攻击者就能消除因兑换导致 Balancer 池中 STA 代币 _records 记录值的增多而导致价格的降低带来的影响,使攻击者始终能以最高的价格兑换 WETH,从而进行获利。
除了 WETH 之外,攻击者使用了同样的方法用 STA 兑换了池中的 WBTC,SNX 及 LINK 代币,并通过 Uniswap 将相应的代币套现,最终折回 WETH,归还闪电贷的 10.4万 WETH。到此攻击完成。
完整攻击过程如下
1、从 dYdX 进行贷款
2、不断地调用 swapExactAmountIn 函数,将 Balancer 池中的 STA 数量降到低点,推高 STA 兑换 其他代币的价格
3、使用 1 STA 兑换 WETH,并在每次兑换完成后(调用 swapExactAmountIn 函数)调用 gulp 函数,覆盖 STA 的余额,使 STA 兑换 WETH 的价格保持在高点
4、使用同样的方法攻击代币池中的其他代币
5、偿还闪电贷贷款
6、获利离场
修复建议
本次攻击主要是因为通缩型代币带来的不兼容性问题,当用户在使用通缩型代币进行兑换的时候,合约没有有效的对接收到的通缩型代币的余额进行校验,导致余额记录错误。从而产生套利。那么修复方案也很简单,即合约在处理兑换逻辑过程中,需要检查进行兑换的两种代币在兑换过程中合约是否收到了相应的代币,保证代币的余额正确记录,不能只是依赖 ERC20 标准中关于转账的返回值,从而避免因代币余额记录错误导致的问题。