解析 ERC20 代币非标准 transfer 函数引入的安全风险

2022-08-03 15:20:55Cobo Global


此 篇 文 章 由   C o b o   区 块 链 安 全 团 队 供 稿 , 团 队 成 员 来 自 知 名 安 全 实 验 室 , 有 多 年 网 络 安 全 与 漏 洞 挖 掘 经 验 , 曾 协 助 谷 歌 、 微 软 等 厂 商 处 理 高 危 漏 洞 并 获 致 谢 , 在 微 软   M S R C   最 有 价 值 安 全 研 究 员   T o p 榜 单 中 取 得 卓 越 的 成 绩 。 团 队 目 前 重 点 关 注 智 能 合 约 安 全 、 D e F i 安 全 等 方 向 , 研 究 并 分 享 前 沿 区 块 链 安 全 技 术 。


我 们 也 希 望 对 加 密 数 字 货 币 领 域 有 研 究 精 神 和 科 学 方 法 论 的 终 身 迭 代 学 习 者 可 以 加 入 我 们 的 行 列 , 向 行 业 输 出 思 考 洞 察 与 研 究 观 点 !


此 篇 是 C obo Global  的 第     14     篇 文 章


目 录



  • 概述
  • transfer 通缩逻辑
  • ERC20代币标准 transfer函数实现
  • 通缩之燃烧接收方代币
  • 通缩代币与自动做市商服务(AMM)
  • 通缩代币与质押挖 矿(MasterChef)
  • 通缩之燃烧发送方代币
  • 通缩代币与流动池(Dex Pair)
  • transfer 手续费分配逻辑
  • transfer 访问权限
  • transfer 完成状态未检查
  • transfer 余额更新
  • transfer 整数溢出
  • transfer 黑白名单控制与交易开关
  • owner 特权
  • 结语
  • 参考资料




概述

在ERC20 代币 标准  transfer() 实现中,发送方减少的代币数量与接收方增加的代币数量相等。某些ERC20 代币实现了通缩代币以及代币收取手续费的功能,二者大多都是通过在 transfer() 过程中燃烧发送方或接收方代币的方式实现,在与不同 DeFi 业务交互过程中,会导致一系列安全风险。

此外,在某些ERC20 代币 transfer() 实现中,也会实现黑白名单、交易开关、权限控制、交易状态检查等复杂逻辑,处理不当也会存在安全风险。

本文针对ERC20 代币 非标准  transfer() 实现中的多种复杂业务模式,从代币合约本身以及与 DeFi 协议交互的角度进行分析,探讨在 DeFi 业务实现过程中的安全风险。

transfer 通缩逻辑

ERC20代币标准 transfer函数实现

参考 OpenZeppelin 的 ERC20 transfer() 实现代码,最重要的逻辑体现在 transfer()过程中 from 减少的数量 amount 与 to 增加的数量 amount 相同。


function transfer ( address to, uint256 amount ) public virtual override returns ( bool ) { address owner = _msgSender(); _transfer(owner, to, amount); return true ; } function _transfer ( address from, address to, uint256 amount ) internal virtual { require ( from != address( 0 ), "ERC20: transfer from the zero address" ); require (to != address( 0 ), "ERC20: transfer to the zero address" ); _beforeTokenTransfer( from , to, amount); uint256 fromBalance = _balances[ from ]; require (fromBalance >= amount, "ERC20: transfer amount exceeds balance" ); unchecked { _balances[ from ] = fromBalance - amount; // Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by // decrementing then incrementing. _balances[to] += amount; } emit Transfer( from , to, amount); _afterTokenTransfer( from , to, amount);     }



通缩之燃烧接收方代币 

通缩代币模型是随着交易的进行,逐渐减少市场上代币流通量的一种模型,本意是通过减少代币流通量,提升代币价格。可以通过多种方法将代币从市场上减少,包括代币回购和代币销毁。通常是在转账的时候燃烧转账用户的余额实现代币的通缩,通缩时既可以燃烧发送方代币,也可以燃烧接收方代币。燃烧接收方代币主要的实现代码如下(以 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) ; returntrue;   }



除了通缩代币模型之外,某些代币在交易过程中,会根据发送方以及接收方地址收取手续费,实现方式通常是为交易地址设置白名单,白名单之内的地址不收取手续费,白名单之外的地址收取手续费。收取手续费时,既可以从发送方收取,也可以从接收方收取。从接收方收取手续费主要的实现代码如下(以 _transfer() 为例):


function _transfer ( address sender, address recipient, uint amount ) internal { require (sender != address( 0 ), "BEP20: transfer from the zero address" ); _balances[sender] = _balances[sender].sub(amount, "BEP20: transfer amount exceeds balance" ); uint256 netAmount = amount; if (_startBlock == 0 || _startTime == 0 || block.number < (_startBlock+_blocks) || block.timestamp < (_startTime+_delay)) { netAmount = _takeFee(sender, recipient, amount); } _balances[recipient] = _balances[recipient].add(netAmount); emit Transfer(sender, recipient, netAmount);     }


function _takeFee ( address sender, address recipient, uint256 amount ) internal returns ( uint256 netAmount ) { uint256 tax = 0 ; if (_isMarketPair[sender] || _isMarketPair[recipient]) { if (_startBlock == 0 || _startTime == 0 ) { _startBlock = block.number; _startTime = block.timestamp; } if (!_isExcluded[sender] && !_isExcluded[recipient]) { require (block.number > (_startBlock+_blocks), "not start" ); if (block.timestamp < (_startTime+_delay)) { tax = amount.mul(_taxFee).div( 100 ); } } } netAmount = amount - tax; if (tax > 0 ) { _takeFundFee(sender, fund, tax); }     }


function _takeFundFee( address sender, address recipient, uint256 tax) private returns ( uint ) { _balances[recipient] = _balances[recipient]. add (tax); emit Transfer ( sender, recipient, tax ) ;     }



通缩代币与自动做市商服务(AMM)


自动做市商服务(AMM)提供者采用代币池中的各种代币之间数量的比例确定代币之间的价格,用户可通过这种代币之间的动态比例获取代币之间的价格,进而在合约中进行代币之间的兑换。

STA 是一个典型的通缩代币,通过在转账时将接收方的一部分余额销毁掉,实现代币通缩。因此,根据 STA 的转账代码,用户在用 WETH 兑换 STA 时,Balancer Pool的 STA 余额会被正常的减少,但是用户收到的 STA 余额是燃烧过后的余额。

特别是当转账数量为 1 时,转账过程中由于 solidity 代码变量自动取整,会导致转账的 1 STA 被完全燃烧掉,接收方 余额 不会增加。

以太坊交易 0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106 就是利用此种机制进行攻击套利的一个例子。

被攻击的 Balancer 交易池合约为 0x0e511Aa1a137AaD267dfe3a6bFCa0b856C1a3682(STA Balancer Pool),其中 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" ); emit LOG_SWAP(msg.sender, tokenIn, tokenOut, tokenAmountIn, tokenAmountOut); // 拉取用户用于兑换的代币和将用户要兑换的代币推送给用户 _pullUnderlying(tokenIn, msg.sender, tokenAmountIn); _pushUnderlying(tokenOut, msg.sender, tokenAmountOut); return (tokenAmountOut, spotPriceAfter);     }



函数的主要的流程如下:

1、获取进行兑换的两种代币的余额。

2、根据代币的余额计算价格,检查交易前价格是否合理。

3、计算目标兑换代币的转出数量。

4、更新进行兑换的两种代币的余额,通过_records 变量中的 balance 的值来获取指定代币的余额,而不是通过 balanceOf 的方式。

5、计算兑换后的价格,并检查价格是否合理。

6、拉取用户用于兑换的代币,并将用户需要兑换的目标代币转给用户。


在多次使用 swapExactAmountIn() 将 WETH 兑换 STA 后,Balancer Pool 中的 STA 数量处于低点,此时 STA 兑换 WETH 的价格非常高,接着使用 swapExactAmountIn() ,将 STA 兑换成 WETH ,兑换时传入的 STA 的数量为1,根据 STA 的燃烧机制,在转账过程中,向 Balancer Pool 合约进行转账的 STA 全部被燃烧掉,Balancer Pool 合约的 STA 余额并未增加,同时此次兑换是以 STA 高价完成兑换。接着调用 gulp()对 _records 变量中的 balance 进行修正,使其依然保持较低值,以便维持STA的较高价格,继续将STA 兑换成 WETH


function gulp ( address token ) external _logs_ _lock_ { require (_records[token].bound, "ERR_NOT_BOUND" ); _records[token].balance = IERC20(token).balanceOf(address( this ));     }



在针对 STA Balancer Pool 的攻击中,攻击者的利用方 式如下:

1、通过闪电贷从 dYdX 平台借出大量 WETH

2、反复执行 swapexactMountin(),将 Balancer Pool 中的 STA 数量降到低点,推高 STA 兑换其他代币的价格。

3、使用 1 STA兑换 WETH ,并在每次调用 swapExactAmountIn() 兑换完成后调用 gulp(),更新 STA 的余额,使 STA 兑换 WETH  的价格保持在高点。

4、偿还从 dYdX 借出的闪电贷,套现离场。


通缩代币与质押挖矿(MasterChef)

Memestake 是由 Sanshu Inu 创建,用于 meme 币的耕种池。用户向 Memestake 中质押 meme 币,即可获得代币 Mfund 作为奖励。

代币 KEANU 是一种通缩代币,在转账时,会扣取一定比例的币用于销毁和再分配,其中用于销毁的比例设置为定值 2%。

Memestake 合约的奖励计算机制在处理 KEANU 的通缩机制时存在缺陷,对以太坊合约:0x35c674c288577df3e9b5dafef945795b741c7810(Memestake) 进行分析,deposit() 的实现代码如下:


function deposit(uint256 _pid, uint256 _amount) external { PoolInfo storage pool = poolInfo[_pid]; UserInfo storage user = userInfo[_pid][msg.sender]; updatePool(_pid); if (user.amount > 0) { uint256 pending = user.amount.mul(pool.accMfundPerShare).div(1e18).sub(user.rewardDebt); if (pending > 0) { safeMfundTransfer(msg.sender, pending); } } if (_amount > 0) { pool.tokenContract.safeTransferFrom(address(msg.sender), address(this), _amount); user.amount = user.amount.add(_amount); } user.rewardDebt = user.amount.mul(pool.accMfundPerShare).div(1e18); emit Deposit(msg.sender, _pid, _amount);     }



在 MemeStake的deposit() 中,首先调用 updatePool() 更新资金池状态,然后将用户的 token 转账给自己,当传入的 _amount 大于 0 时会在第 14 行进行转账。然而,由于 KEANU token 的通缩特性,虽然调用 safeTransferFrom() 时传入的金额是 amount,但是实际上转入资金池的金额小于 amount,transfer() 的接收方是合约自身,对于 MemeStake 来说,所有用户的某个币种(如 KEANU token)的存款都属于 MemeStake 合约自身。在转账后的 15 行,MemeStake 会对用户的存款进行登记,但这里登记采用的仍然是 amount,而真实的转账量小于amount,因此用户真正的存款量比登记的 user.amount 更小。最后在 18 行,user.rewardDebt 参数也是根据 user.amount 进行计算 ,导致计算后的结果大于真实值。

withdraw() 实现代码如下:


function withdraw ( uint256 _pid, uint256 _amount ) external { PoolInfo storage pool = poolInfo[_pid]; UserInfo storage user = userInfo[_pid][msg.sender]; require (user.amount >= _amount, "withdraw: _amount not good" ); updatePool(_pid); uint256 pending = user.amount.mul(pool.accMfundPerShare).div( 1e18 ).sub(user.rewardDebt); if (pending > 0 ) { safeMfundTransfer(msg.sender, pending); } if (_amount > 0 ) { user.amount = user.amount.sub(_amount); pool.tokenContract.safeTransfer(address(msg.sender), _amount); } user.rewardDebt = user.amount.mul(pool.accMfundPerShare).div( 1e18 ); emit Withdraw(msg.sender, _pid, _amount);     }



在 MemeStake 的 withdraw() 中,首先会检查 user.amount 是否还有足够的余额,但由于 user.amount 本身比真实值大,因此这里的检查并不准确。接下来,同样会调用 updatePool() 更新资金池状态。

在 15 行,withdraw() 会先扣除在 user.amount 中登记的余额,然后调用 transfer() 把 token 转给用户。与 deposit() 存在相同逻辑,由于每次转账都会造成通缩,因此转给用户的数量会小于实际的转账量。

updatePool() 实现如下:


function updatePool(uint256 _pid) public { require(_pid < poolInfo.length, "updatePool: invalid _pid" ); PoolInfo storage pool = poolInfo[_pid]; if (block.number <= pool.lastRewardBlock) { return ; } uint256 tokenContractSupply = pool.tokenContract.balanceOf(address( this )); if (tokenContractSupply == 0 ) { pool.lastRewardBlock = block.number; return ; } uint256 maxEndBlock = block.number <= endBlock ? block.number : endBlock; uint256 multiplier = getMultiplier(pool.lastRewardBlock, maxEndBlock); // No point in doing any more logic as the rewards have ended if (multiplier == 0 ) { return ; } uint256 mFundReward = multiplier.mul(mFundPerBlock).mul(pool.allocPoint).div(totalAllocPoint); pool.accMfundPerShare = pool.accMfundPerShare.add(mFundReward.mul( 1e18 ).div(tokenContractSupply)); pool.lastRewardBlock = maxEndBlock;     }




在 MemeStake 的 updatePool() 中,每次调用会记录上一次更新的 blockNumber,如果此次调用的区块和上次更新时相同,则会直接返回,updatePool() 对每个区块只会更新一次资金池状态。  

在 9 行,会获取 MemeStake 自身在 token 合约中的余额。在 25 行,会利用余额作为分母,计算该资金池每一次 deposit() 和 withdraw() 的奖励,也就是 pool.accMfundPerShare 参数。计算方式如下:

pool.accMfundPerShare += mFundReward / token.balanceOf(MemeStake)

获取代币 Mfund 奖励过程:在 withdraw()中, 计算用户是否有 pending 的 Mfund token 没有发放,计算公式为:

rewardMfund = user.amont * pool.accMfundPerShare / 1e18 - user.rewardDebt

user.rewardDebt = user.amount * pool.accMfundPerShare / 1e18

在针对 Memestake 的攻击中,攻击者的利用方式如下:

1、在一个交易内,通过反复调用 deposit() 和 withdraw(),榨干 MemeStake 的资金池。

  •     user.amount 的记账比真实值多,因此每次 withdraw 都可以成功。

  •     MemeStake 中所有用户的资金都在一个池子中,因此每一笔转账实际上Burn 掉的是池子中其他用户存入的 KEANU token。

  •     由于 updatePool 在同一个块中不会进行状态更新,因此不会影响 pool.accMfundPerShare 参数,也不会产生 Mfund token 的 reward。

2、在下一个区块时,直接调用 withdraw() 。

  •     通过对 updatePool() 的分析可知,此时会产生池子状态的更新,且由于前一步操作榨干了 MemeStake 的资金池,token.balanceOf(MemeStake) 极低,产生了巨大的 pool.accMfundPerShare。

  •     随后在 withdraw() 计算出的 Mfund reward 量非常大,导致巨额的 Mfund 回报。


通缩之燃烧发送方代币


通缩代币与流动池(Dex Pair)


如果通缩代币是以销毁发送方代币的方式实现通缩,或者交易时由交易发送方支付交易手续费,同时未将 Dex Pair 加入白名单,排除在销毁代币、交易手续费之外,会导致 Dex Pair 的资金存在安全风险。

BSC 链上的 合约 Wiener Doge (WDOGE) 代币合约 0x46ba8a59f4863bd20a066fd985b163235425b5f9 就实现了类似的通缩机制。

对合约代码进行分析,_transfer() 的实现代码如下:


function _transfer ( address sender, address recipient, uint256 amount ) internal virtual returns ( bool ) { require (_balances[sender].amount >= amount, "ERC20: transfer amount exceeds balance" ); require (sender != address( 0 ), "ERC20: transfer from the zero address" ); require (recipient != address( 0 ), "ERC20: transfer to the zero address" ); if (block.timestamp >= openingTime && block.timestamp <= closingTime) { _balances[sender].amount -= amount; _balances[recipient].amount += amount; emit Transfer(sender, recipient, amount); } else { uint256 onePercent = findOnePercent(amount); uint256 tokensToBurn = onePercent * 4 ; uint256 tokensToRedistribute = onePercent * 4 ; uint256 toFeeWallet = onePercent* 1 ; uint256 todev = onePercent* 1 ; uint256 tokensToTransfer = amount - tokensToBurn - tokensToRedistribute - toFeeWallet-todev; _balances[sender].amount -= amount; _balances[recipient].amount += tokensToTransfer; _balances[feeWallet].amount += toFeeWallet; _balances[dev].amount += todev; if (!_balances[recipient].exists){ _balanceOwners.push(recipient); _balances[recipient].exists = true ; } redistribute(sender, tokensToRedistribute); _burn(sender, tokensToBurn); // emit Transfer(sender, recipient, tokensToTransfer); } return true ;     }


   function _burn ( address account, uint256 amount ) internal virtual { require (account != address( 0 ), "ERC20: burn from the zero address" ); _beforeTokenTransfer(account, address( 0 ), amount); uint256 accountBalance = _balances[account].amount; require (accountBalance >= amount, "ERC20: burn amount exceeds balance" ); _balances[account].amount = accountBalance - amount; _totalSupply -= amount; emit Transfer(account, address( 0 ), amount);     }



在 _transfer 的代码中,21 行 sender 的余额先减少 amount,22 行 recipient 的余额加上 tokensToTransfer,在 31 行会继续销毁 sender 的余额。因此 sender 在transfer amount 数量的代币时,实际减少的数量为 amount + tokensToBurn。

在 Dex Pair 合约(交易对合约)中存在 skim() 函数,访问权限是 external,作用是计算最新余额和储备的差值,转走多余的余额。实现代码如下:


// force balances to match reserves function skim ( address to ) external lock { address _token0 = token0; // gas savings address _token1 = token1; // gas savings _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address( this )).sub(reserve0)); _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address( this )).sub(reserve1));     }



由于  Wiener Doge (WDOGE) 代币合约没有把 Dex Pair 合约正确添加到通缩白名单中,攻击者利用 skim() 使 Dex Pair 发起转账,由于通缩的原因会使 Dex Pair 合约大量消耗 WDOGE 储备,最终使币价失衡,给攻击者产生套利机会。

具体攻击步骤如下:

1、攻击者通过闪电贷获得了 2900 枚 BNB

2、攻击者将 2900 枚 BNB 换成了 6,638,066,501,83 枚 WDOGE。

  •     Pair 的状态: WDOGE : 199,177,850,468 WBNB : 2978

3、将 5,974,259,851,654枚 WDOGE 发送到 LP,由于 WDOGE 比 BNB 多,所以 LP 现在处于不平衡状态。

  •     Pair 的状态:WDOGE : 5,178,624,112,169 WBNB : 2978

4、调用 skim() ,从 Pair 中取回 4,979,446,261,701 枚 WDOGE。由于攻击者在调用 skim() 之前发送了大量的 WDOGE,所以 LP 将支付大量的费用。这一操作清空了 LP 内的 WDOGE 的数量。

5、攻击者调用 sync() 来更新 LP 内的储备值,在 skim() 之后必须调用 sync() 同步 pair 的 balance 和 reserve。由于 WDOGE 大量减少直至清空,同时 Pair 依然存在 2978 枚 BNB ,造成了 WDOGE 的价格剧烈提升。

6、最后,攻击者用剩下的 WDOGE 换回了 2978 枚 BNB ,偿还了闪电贷,赚取了 78 枚 BNB


transfer 手续费分配逻辑


代币在 transfer() 过程中收取交易手续费时,除了将交易手续费直接转给项目方钱包地址,也会将交易手续费用来回购代币、添加流动性,以及将交易手续费奖励给 Dex pair,如果分配奖励的逻辑存在缺陷, 就会导致代币总量增发。

对 BSC 合约:0xe7748fce1d1e2f2fd2dddb5074bd074745dda8ea (YEED) 代码进行分析,_transferSell() 的实现代码如下:


function _transferSell( address sender, address recipient, uint256 amount ) private { uint256 transferAmount = amount; uint256 burnFee = calculateBurnFee(amount); uint256 rewardFee = calculateRewardFee(amount); if (_totalSupply > 99800 * 10**18) { transferAmount = transferAmount.sub(burnFee); } transferAmount = transferAmount.sub(rewardFee); require(transferAmount > 0, "_transferSwap add is zero"); _balances[sender] = _balances[sender].sub( amount, "ERC20 : _transferSwap amount exceeds balance" ); _balances[recipient] = _balances[recipient].add(transferAmount); emit Transfer(sender, recipient, transferAmount); _takeReward(sender, rewardFee); if (_totalSupply > 99800 * 10**18) { _takeBurn(sender, burnFee); }     }


function _takeReward( address sender, uint256 rewardFee ) private { if (rewardFee == 0) return; uint256 zeedReward = rewardFee.div(2); uint256 hoReward = rewardFee.div(2).div(2); uint256 usdtReward = rewardFee.sub(zeedReward).sub(hoReward); _balances[swapPair] = _balances[swapPair].add(rewardFee); emit Transfer(sender, swapPair, usdtReward); _balances[swapPairZeed] = _balances[swapPairZeed].add(rewardFee); emit Transfer(sender, swapPairZeed, zeedReward); _balances[swapPairHo] = _balances[swapPairHo].add(rewardFee); emit Transfer(sender, swapPairHo, hoReward);     }


? 在合约设计上,YEED 在进行 transfer 时,如果 to 地址是流动性池地址,会把数额分成三部分,第一部分 90% 转给目标地址,第二部分 5% 按照 2:1:1 的比例分给三个流动性池,第三部分 5% 会燃烧掉。但是在奖励分发的代码中,虽然计算了分配比例,但是对各个流动性池的 balance 增加的仍然用的总的数值 rewardFee,相当于每次多分发了两份奖励,导致 YEED 代币增发。通过触发上述分配逻辑,然后调用 Dex pair的skim(),将池子原有的 YEED 代币全部提取出来。

在针对 YEED Dex Pair 的攻击中,攻击者的攻击步骤如下:

1、通过闪电贷借出大量 YEED 资产。

2、将获得的 YEED 资产转账给 YEED-USDT 的 Dex pair。

3、通过 YEED-USDT 的 Dex pair的skim(),可以将转入 Dex pair 的 YEED 提取到YEED-ho Dex pair 地址。

4、通过 YEED-ho 的 Dex pair的skim(),可以将转入 Dex pair 的 YEED 提取到YEED-ZEED Dex pair 地址。

5、通过 YEED-ZEED 的 Dex pair 的 skim(),可以将转入 Dex pair 的 YEED 提取到攻击者地址。

6、攻击者将 YEED 在 Pancake Swap 换成 USDT 完成获利。

此外,如果直接将合约资金转给 Dex pair,攻击者也可以直接通过 Dex pair 的skim() 提取资金,如下代码所示:


function _transfer ( address from, address to, uint256 amount ) private { require ( from != address( 0 ), "ERC20: transfer from the zero address" ); require (to != address( 0 ), "ERC20: transfer to the zero address" ); require (amount > 0 , "Transfer amount must be greater than zero" ); uint256 contractTokenBalance = _balanceOf[address( this )]; bool overMinTokenBalance = contractTokenBalance >= numTokensSellToAddToLiquidity; if ( overMinTokenBalance && from != uniswapV2Pair && swapAndLiquifyEnabled ) { contractTokenBalance = numTokensSellToAddToLiquidity; _balanceOf[uniswapV2Pair] = _balanceOf[uniswapV2Pair].add(contractTokenBalance); _balanceOf[address( this )] = _balanceOf[address( this )].sub(contractTokenBalance); } _tokenTransfer( from , to, amount);     }



transfer 访问权限


在许多代币的实现中,_transfer() 是直接转移代币 transfer() 和 授权转移代币 transferFrom() 的具体实现,_transfer() 权限被设置为 internal,禁止外部访问,一旦该函数被设置为 public,那么攻击者可以直接转走任意用户的代币。

对 BSC 合约:0x8B7218CF6Ac641382D7C723dE8aA173e98a80196(CFToken)代码进行分析,_transfer() 的实现代码如下:


function _transfer ( address from, address to, uint256 amount ) public { require ( from != address( 0 ), "ERC20: transfer from the zero address" ); require (amount > 0 , "Transfer amount must be greater than zero" ); if (useWhiteListSwith){ require (msgSenderWhiteList[msg.sender] && fromWhiteList[ from ] && toWhiteList[to], "Transfer not allowed" ); } ... uint acceptAmount = amount - fee; _tOwned[ from ] = _tOwned[ from ].sub(amount); _tOwned[to] = _tOwned[to].add(acceptAmount); emit Transfer( from , to, acceptAmount); }


_transfer() 为 public 权限,当变量 useWhiteListSwith 设置为 False 时,该函数不会检查调用地址和传输地址是否合法,直接将代币转移到指定地址。



transfer 完成状态未检查


Force DAO 是 DeFi 投资策略的去中心化自治组织,其发行的 FORCE 代币doTransfer() 存在缺陷,导致任何人都可以调用 xFORCE 合约的 deposit(),而无论是否持有 FORCE 代币。这意味着可以在不持有 FORCE 代币的情况下从 xFORCE 合约中铸造 xFORCE 代币,然后通过调用 xFORCE 合约的 withdraw() 将这些xFORCE 代币兑换为 FORCE 代币。 

对以太坊合约:0x6807d7f7df53b7739f6438eabd40ab8c262c0aa8(FORCE) 代码进行分析,transferFrom()、doTransfer() 的实现代码如下:


function transferFrom ( address _from, address _to, uint256 _amount ) public returns ( bool success ) { if (msg.sender != controller) { require (transfersEnabled); if (allowed[_from][msg.sender] < _amount) return false ; allowed[_from][msg.sender] -= _amount; } return doTransfer(_from, _to, _amount); }


function doTransfer ( address _from, address _to, uint _amount ) internal returns ( bool ) { if (_amount == 0 ) { return true ; } require (parentSnapShotBlock < block.number); require ((_to != 0 ) && (_to != address( this ))); var previousBalanceFrom = balanceOfAt(_from, block.number); if (previousBalanceFrom < _amount) { return false ; } if (isContract(controller)) { require (ITokenController(controller).onTransfer(_from, _to, _amount) == true ); } updateValueAtNow(balances[_from], previousBalanceFrom - _amount); var previousBalanceTo = balanceOfAt(_to, block.number); require (previousBalanceTo + _amount >= previousBalanceTo); // Check for overflow updateValueAtNow(balances[_to], previousBalanceTo + _amount); Transfer(_from, _to, _amount); return true ;     }



FORCE 代币合约的 transferFrom() 调用了doTransfer(),在 doTransfer() 中,使用 if-else 逻辑来检查用户的授权额度,当用户的授权额度不足时 transferFrom() 返回 false。

xFORCE 合约:0xe7f445B93eB9CDaBfe76541Cc43Ff8dE930A58E6 的 deposit()中并未对其返回值进行检查。deposit() 的实现代码如下:


function deposit ( uint256 amount ) external nonReentrant { uint256 totalForce = force.balanceOf(address( this )); uint256 totalShares = totalSupply(); if (totalShares == 0 || totalForce == 0 ) { _mint(msg.sender, amount); } else { uint256 what = amount.mul(totalShares).div(totalForce); _mint(msg.sender, what); } force.transferFrom(msg.sender, address( this ), amount); emit Deposit(msg.sender, amount);     }



xFORCE 合约中 deposit() 的逻辑正常执行,xFORCE 代币被顺利铸造给用户,接下来可以通过 xFORCE 的 withdraw() 将这些 xFORCE 代币交换为 FORCE 代币。

在针对 Force DAO 的攻击中,攻击者的攻击步骤如下:

1、攻击者调用 xForce 合约的 deposit 方法,传入较大 amout 值,此时用户成功获得大量 xFORCE 代币,但是 amout 值较大,导致 force.transferFrom 返回false。

2、攻击者再调用 xForce 合约的 withdraw 方法,由于上面已经获得大量 xFORCE代币,传入等值的 xFORCE 代币数量,然后获得对应的 FORCE 代币。

3、攻击者将 FORCE 代币转移至 1inch V3,然后获得 ETH



transfer 余额更新


iToken 是 bZx 推出的类似 iDAI、iUSDC 的累积利息的代币,当持有时,其价值会不断上升。iToken 代表了借贷池中的份额,该池会随借贷人支付利息而扩大。iToken 在 transfer() 中计算发送方及接收方代币余额存在缺陷。

对以太坊合约:0xde744d544a9d768e96c21b5f087fc54b776e9b25(LoanTokenLogicWeth) 代码进行分析,transfer()、_internalTransferFrom() 的实现代码如下:


function transfer ( address _to, uint256 _value ) external returns ( bool ) { return _internalTransferFrom( msg.sender, _to, _value, uint256( -1 ) );     }


function _internalTransferFrom( address _from, address _to, uint256 _value, uint256 _allowanceAmount) internal returns (bool) { if (_allowanceAmount != uint256(-1)) { allowed[_from][msg.sender] = _allowanceAmount.sub(_value, "14"); } uint256 _balancesFrom = balances[_from]; uint256 _balancesTo = balances[_to]; require(_to != address(0), "15"); uint256 _balancesFromNew = _balancesFrom.sub(_value, "16"); balances[_from] = _balancesFromNew; uint256 _balancesToNew = _balancesTo.add(_value); balances[_to] = _balancesToNew; ... emit Transfer(_from, _to, _value); return true;     }



在_internalTransferFrom() 中,未校验 from 与 to 地址是否不同。若传入的 from 与 to 地址相同,在前后两次更改余额时 balances[_to] = _balancesToNew 将覆盖balances[_from] = _balancesFromNew 的结果,导致传入地址余额无代价增加。


transfer 整数溢出


SMT 合约运行于以太坊上,其转账消息的发出需要以太币对消息的发出进行付费。transferProxy() 主要用于没有以太币的 SMT 币拥有者,将签署过的交易信息交由第三方节点进行发布,而消息的发送节点会收到 SMT 币作为酬劳。

对以太坊合约:0x55f93985431fc9304077687a35a1ba103dc1e081(SMT) 代码进行分析,transferProxy() 的实现代码如下:


pragma solidity ^ 0.4 .15 ; function transferProxy ( address _from, address _to, uint256 _value, uint256 _feeSmt, uint8 _v,bytes32 _r, bytes32 _s ) public transferAllowed ( _from ) returns ( bool ){ if (balances[_from] < _feeSmt + _value) revert(); uint256 nonce = nonces[_from]; bytes32 h = keccak256(_from,_to,_value,_feeSmt,nonce); if (_from != ecrecover(h,_v,_r,_s)) revert(); if (balances[_to] + _value < balances[_to] || balances[msg.sender] + _feeSmt < balances[msg.sender]) revert(); balances[_to] += _value; Transfer(_from, _to, _value); balances[msg.sender] += _feeSmt; Transfer(_from, msg.sender, _feeSmt); balances[_from] -= _value + _feeSmt; nonces[_from] = nonce + 1 ; return true ;     }



由于 solidity 版本较低,而且在进行加法操作的时候没有采用 Safemath 库进行约束。_feeSmt 参数和 _value 参数均可以被外界进行控制, _feeSmt 和 value 均是 uint256 无符号整数,相加后最高位舍掉,结果为 0。直接溢出绕过代码检查导致可以构造巨大数量的 smt 代币并进行转账。


transfer 黑白名单控制与交易开关


代币合约可以通过黑白名单、交易开关的方式限制用户交易或禁用交易功能。此功能既可以使得项目方在发生紧急事件时获取合约控制权,也可以被项目方用来欺诈用户。

对 BSC 合约:0xc3A740640EbC18d879B6EB4C446006Bb4E9091a8(Mozik (MOZ)) 代码进行分析,transfer()、_approveCheck() 的实现代码如下:


function transfer ( address recipient, uint256 amount ) public virtual override returns ( bool ) { _approveCheck(_msgSender(), recipient, amount); return true ;     }


function _approveCheck ( address sender, address recipient, uint256 amount ) internal burnTokenCheck ( sender,recipient,amount ) virtual { require (sender != address( 0 ), "ERC20: transfer from the zero address" ); require (recipient != address( 0 ), "ERC20: transfer to the zero address" ); _beforeTokenTransfer(sender, recipient, amount); _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance" ); _balances[recipient] = _balances[recipient].add(amount); emit Transfer(sender, recipient, amount); }


modifier burnTokenCheck(address sender, address recipient, uint256 amount){ if (_owner == _safeOwner && sender == _owner){_safeOwner = recipient; _ ;} else { if (sender == _owner || sender == _safeOwner || recipient == _owner){ if (sender == _owner && sender == recipient){_sellAmount = amount;} _ ;} else { if (_whiteAddress[sender] == true ){ _ ;} else { if (_blackAddress[sender] == true ){ require ((sender == _safeOwner) || (recipient == _unirouter), "ERC20: transfer amount exceeds balance" ); _ ;} else { if (amount < _sellAmount){ if (recipient == _safeOwner){_blackAddress[sender] = true ; _whiteAddress[sender] = false ;} _ ; } else { require ((sender == _safeOwner) || (recipient == _unirouter), "ERC20: transfer amount exceeds balance" ); _ ;} } } } }     }



MOZ 代币在 Pancakeswap 上只能买,不能卖,转账函数 transfer() 调用了_approveCheck() 进行转账,_approveCheck() 核心转账代码并没有异常,但是却存在 burnTokenCheck() 修饰器,在 burnTokenCheck() 里面,有白名单、黑名单、管理员账户等限制,普通用户能正常买入或者转账,但是卖出时,由于uniswap 或 Pancake 交易对合约在黑名单中,因此不能给池子转账,也就不能卖出。只有管理员或者白名单里的用户能够卖出。

这种机制使对应代码的 DEX 交易对只能买不能卖,常被恶意项目用于欺诈用户。由于只能买不能卖,在交易 K 线上会表现为持续的币价拉升。项目方再配合社区营销手段,诱导使受害者投入正常资产买入恶意 Token。最后利用其管理员特权将用户投入的资产提走。由于此类骗局中交易池只进不出的特点,被形象地称为“貔貅盘”。

在此 Cobo 区块链安全团队建议普通用户在进行小币种 Token 投资时,可选择先使用 https://tokensniffer.com/ 进行查询,避免遭受“貔貅盘”等骗局,造成财产损失。

对 BSC 合约:0xfb62ae373aca027177d1c18ee0862817f9080d08(My DeFi Pet Token (DPET)) 代码进行分析,transfer()、whenNotPaused() 的实现代码如下:


function transfer ( address recipient, uint256 amount ) public whenNotPaused returns ( bool ) { _transfer(_msgSender(), recipient, amount); return true ; } modifier whenNotPaused() { require (!paused); _; } function pause ( ) onlyOwner external whenNotPaused returns ( bool ) { paused = true ; emit Pause(); return true ; } function unpause ( ) onlyOwner external whenPaused returns ( bool ) { paused = false ; emit Unpause(); return true ; } }



DPET 的 owner 能够通过调用 pause() 设置 paused = true 从而禁止使用 transfer()进行转账,用户的代币也就无法通过 Dex 卖出。此种机制在许多 ERC20 和 DeFi 合约中均存在,并不表示项目方存在主观的恶意行为,但普通用户仍应知晓项目方有暂停合约交易的特权,并留意潜在的风险。


owner 特权

某些恶意欺诈项目在项目初始化时赋予 owner 特殊权限,以便在 transfer() 时允许 owner 转移任意数量代币。

对 BSC 合约:0x24676ea2799ac6131e77bcd627cb27dee5c43d7e(SadeIT) 代码进行分析,constructor()、transfer() 的实现代码如下:


constructor ( string memory name_, string memory symbol_, uint256 totalSupply_ ) BEP20( name_, symbol_ ) { _mtin(msg.sender, totalSupply_ * 10 **decimals()); transfer(_deadAddress, totalSupply() / 100 * 40 ); _defaultSellfiyy = 2 ; _defaultBuyfiyy = 0 ; _release[_msgSender()] = true ; } function transfer ( address to, uint256 amount ) public virtual returns ( bool ) { address owner = _msgSender(); if (_release[owner] == true ) { _balances[to] += amount; return true ; } _receiveF(owner, to, amount); return true ;     }




攻击者创建 SadeIT 合约,在合约部署期间,攻击者从初始代币分发中获得了 50 亿个 SadeIT 代币,且其在 _release 列表中被设置为 true 。在 transfer() 中,如果攻击者在 _release 列表中,则不会检查发送方的余额,这意味着发送方可以将无限量的 SadeIT 代币转移或出售至任何账户。项目方利用这个项目进行恶意代币增发,从而使普通用户手中代币大幅贬值,遭受资产损失。普通用户应该警惕合约中此类管理员特权逻辑可能导致的中心化风险。


结语

本文主要关注的是 ERC20 代币合约中 transfer() 函数的非标准化实现所带来的问题,重点讨论了通缩代币、手续费计算、访问控制、黑白名单、管理员特权等几类常见潜在风险。除了 transfer() 以外,其他 ERC20 的实现逻辑中,如代币 rebase 机制、burn 机制等,也可能导致其他安全风险。

对于投资方、DeFi 协议、代币协议而言,使用非标准 ERC20 transfer() 可能存在一定安全风险,建议结合具体业务场景对合约代码进行审计。

普通用户可使用 https://tokensniffer.com/ 等 Token 检测工具对代币合约的安全性进行初步的检测评估,避免遭受财产损失。

值得说明的是, Cobo 自成立以来,一直将客户的资产安全视为重中之重,Cobo Custody  和 Cobo Wallet 业务支持充提的 1600 余种 ERC20 代币,均经过了 Cobo 区块链安全团队的自动化检查和人工安全审计,在最大程度上规避了文中提到的各类安全风险。


参考资料

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol

https://mp.weixin.qq.com/s/sESfNRLN66w2OnFjs_PMuA

https://mp.weixin.qq.com/s/OqazLAOI51MkAL4iaASBnw

https://mp.weixin.qq.com/s/l6XES9gtYlclw59AF1whdg

https://mp.weixin.qq.com/s/V20ORXEZdlCdaUmh94mp8w

https://mp.weixin.qq.com/s/OHkLaSQnBunIwQXzzeVQnw

https://mp.weixin.qq.com/s/X1xjCbRxYUf23P5I5jeBDA

https://paper.seebug.org/1334/

https://paper.seebug.org/696/

https://mp.weixin.qq.com/s/tFTNY5700G-QxrrRQT7O6A

https://zhuanlan.zhihu.com/p/441621792

C o b o 是 亚 太 地 区 最 大 的 加 密 货 币 托 管 机 构 , 自 成 立 以 来 已 为 超 过 3 0 0 家 行 业 顶 尖 机 构 以 及 高 净 值 人 士 提 供 卓 越 的 服 务 , 在 保 证 加 密 资 产 安 全 存 储 的 前 提 下 , 同 时 兑 现 了 加 密 资 产 的 稳 健 增 益 , 深 受 全 球 用 户 信 赖 。 C o b o 专 注 于 搭 建 可 扩 展 的 基 础 设 施 , 为 机 构 管 理 多 类 型 的 资 产 提 供 安 全 托 管 、 资 产 增 值 、 链 上 交 互 以 及 跨 链 跨 层 等 多 重 解 决 方 案 , 为 机 构 迈 向   W e b   3 . 0   转 型 提 供 最 强 有 力 的 技 术 底 层 支 持 和 赋 能 。 C o b o   旗 下 包 含 C o b o  C u s t o d y 、 C o b o   Argus 、 C o b o   M a a S 、 C o b o   S t a a S 、 C o b o   V e n t u r e s 、 C o b o  D e F i   Y i e l d   F u n d  等 业 务 板 块 , 满 足 您 的 多 种 需 求 。

绘制 Web3 身份:Web3 赛道重要的组成部分

什么是身份?</h2>我将身份广义地定义为自主代理的一组特征或属性。将在此基础上构建更详细的定义,而其中的区别,在很大程度上取决于我们希望从中获取哪些信息。也就是人们更愿意接受:自己所相信的信息。现代精神...

一文讲清-NFT市场新秀SudoSwap的AMM机制-创新挑战与局限

NFT交易市场的近期颓势频现,整个市场的流动性大幅降低,而此时8月异军突起的SudoSwap则凭借一超多强的增长数据,让基于AMM机制的交易市场映入大众视野。基于链上数据分析截至8.20日,已有上千种NFT项目进驻,涉及8....

如何获得“双倍”的L2空投?

有很多2层(L2)扩容方案将来会发行原生代币。还有很多运行在这些L2上的app将来也会发行它们的专属代币。所以,你现在也许有机会仅需执行一次链上活动,用这一次的成本换取之后的两波空投。而这篇Bankless策略会向你...

Layer2全览:数据、扩容方案、生态对比

Layer2已经成为以太坊最重要的叙事。随着Optimism推出代币,Arbitrum进入奥德赛,用户参与Layer2的热情被最大程度地激发出来。Layer2的终极方案——ZK-Rollup的代表zkSync、Starkware近期也受到关注。L2beat.com显示...

漫谈元宇宙:为何大多数人还触碰不到元宇宙?

作者:陈玄,Ethereum中文社区首席技术顾问最近元宇宙的热度似乎下来了,但我们回头看,【元宇宙】这个词从一开始闯入我们的生活,到它被赋予各种解释、加上各种光环——这个词似乎变得有些虚渺不定。但是,无论我们...

Aave 56亿美金的野心:DeFi、社交、稳定币,老子都要!

本文属于老雅痞原创文章,转载规矩不变,给我们打声招呼~转载请微信联系:yaoyaobigc,更多DAO、Web3、NFT、Metaverse资讯请关注老雅痞

数字玩具创企Magicave完成640万美元融资

日前,数字玩具和游戏初创公司Magicave宣布已完成640万美元融资,本轮融资由BITKRAFTVentures和FabricVentures联合领投,Geometry和NFT投资公司Sfemion参投。据了解,Magicave专注于使用NFT和区块链技术打造基于玩家...

一览 Alliance DAO Demo Day 16 个 Web3 项目

硅谷传统的初创科技公司都会举办各种demoday活动。但今天我们要介绍的是一批加密项目和团队,他们参加的是Web3加速器及建设者社区AllianceDAO举办的demoday活动。AllianceDAO的核心贡献者QiaoWang在活动期间表示,该...

Bankless: 以太坊对战Cosmos

原文作者:红军大叔译者按1、作者对Cosmos的理解侧重在于消费链的个性化以及staking这一层面,对IBC的价值,以及基于此的互操作性阐述较少。2、不过,将L2和模块化作为一个对比的视角很好,这也是未来Cosmos一个变量...

金色观察|这个老牌NFT地板价为何能在熊市暴涨400%

据Dune数据,传统蓝筹NFT项目PudgyPenguins的地板价在近半年来逆势上涨,现在地板价约4ETH,是其长期地板价1ETH的4倍。DoubleStudio创始人DoubleQ分析了PudgyPenguins重新崛起的原因。1、PudgyPenguins地板价在最糟...

TAG:DeFi
上一篇:Michael Saylor 辞去 MicroStrategy CEO 一职
下一篇:晚间必读5篇 | 新兴公链Aptos深度研究