Paraluni 被黑分析
前言
Paraluni (平行宇宙)是新加坡 Parallel Universe 基金會發布的一個 基於幣安智慧鏈的 DeFi 專案,更多相關內容見此處。在 2022 年 03 月 13 日,Paraluni 遭受黑客攻擊,損失約 170 萬美元。
本次復現是參考學習了各位大佬的復現教程一步一步摸索記錄下來的,更多的是對攻擊過程中每個步驟的求證(根據這個,我們可以得知它做了什麼),而對於一些啟發性的思路還有所欠缺(你怎麼就知道要看這裡?),在文末的引用文章中可以學習一下大佬們的思路。
相關地址
攻擊者地址:https://bscscan.com/address/0x94bc1d555e63eea23fe7fdbf937ef3f9ac5fcf8f
攻擊交易:https://bscscan.com/tx/0x70f367b9420ac2654a5223cc311c7f9c361736a39fd4e7dff9ed1b85bab7ad54
攻擊合約:https://bscscan.com/address/0x4770b5cb9d51ecb7ad5b14f0d4f2cee8e5563645
UBT(ukraine bad token)代幣合約:https://bscscan.com/address/0xca2ca459ec6e4f58ad88aeb7285d2e41747b9134
UGT(russia good token)代幣合約:https://bscscan.com/address/0xbc5db89ce5ab8035a71c6cd1cd0f0721ad28b508
Masterchef 合約:https://bscscan.com/address/0xa386f30853a7eb7e6a25ec8389337a5c6973421d#code
ParaPair 合約:https://bscscan.com/address/0x3fd4fbd7a83062942b6589a2e9e2436dd8e134d4#code
ParaProxy 合約(代理 Masterchef 合約):https://bscscan.com/address/0x633fa755a83b015cccdc451f82c57ea0bd32b4b4#code
攻擊流程分析
首先通過 tenderly 和 blocksec 分析交易過程,可以看到這筆交易的頭部和尾部有借款還款操作,可以判斷攻擊者利用了閃電貸進行攻擊。攻擊者通過閃電貸貸出了 15w 的 USDT 和 BUSD。
交易的頭部,借出代幣
交易的尾部,歸還代幣
然後攻擊者呼叫 ParaRouter.addLiquidity
將 15w 的 USDT 和 BUSD 新增到對應的 ParaPair
合約中,獲取流動性代幣 LP ,並將其轉移到改寫了 transferFrom
函式的 ERC20 標準的 UBT 代幣合約(0xca2)中。
然後攻擊合約(0x477)利用 USDT-BUSD 的 pid 以及 UBT-UGT 代幣,通過 ParaProxy
代理的 MasterChef.depositByAddLiquidity
新增流動性。
呼叫 MasterChef.depositByAddLiquidity
函式時的引數輸入情況。
MasterChef
合約中的內部呼叫 depositByAddLiquidity
→ depositByAddLiquidityInternal
→ addLiquidityInternal
相關函式程式碼
// MasterChef
function depositByAddLiquidity(uint256 _pid, address[2] memory _tokens, uint256[2] memory _amounts) external{
require(_amounts[0] > 0 && _amounts[1] > 0, "!0");
address[2] memory tokens;
uint256[2] memory amounts;
(tokens[0], amounts[0]) = _doTransferIn(msg.sender, _tokens[0], _amounts[0]);
(tokens[1], amounts[1]) = _doTransferIn(msg.sender, _tokens[1], _amounts[1]);
depositByAddLiquidityInternal(msg.sender, _pid, tokens,amounts);
}
function depositByAddLiquidityInternal(address _user, uint256 _pid, address[2] memory _tokens, uint256[2] memory _amounts) internal {
PoolInfo memory pool = poolInfo[_pid];
require(address(pool.ticket) == address(0), "T:E");
uint liquidity = addLiquidityInternal(address(pool.lpToken), _user, _tokens, _amounts);
_deposit(_pid, liquidity, _user);
}
function addLiquidityInternal(address _lpAddress, address _user, address[2] memory _tokens, uint256[2] memory _amounts) internal returns (uint){
//Stack too deep, try removing local variables
DepositVars memory vars;
approveIfNeeded(_tokens[0], address(paraRouter), _amounts[0]);
approveIfNeeded(_tokens[1], address(paraRouter), _amounts[1]);
vars.oldBalance = IERC20(_lpAddress).balanceOf(address(this));
(vars.amountA, vars.amountB, vars.liquidity) = paraRouter.addLiquidity(_tokens[0], _tokens[1], _amounts[0], _amounts[1], 1, 1, address(this), block.timestamp + 600);
vars.newBalance = IERC20(_lpAddress).balanceOf(address(this));
require(vars.newBalance > vars.oldBalance, "B:E");
vars.liquidity = vars.newBalance.sub(vars.oldBalance);
addChange(_user, _tokens[0], _amounts[0].sub(vars.amountA));
addChange(_user, _tokens[1], _amounts[1].sub(vars.amountB));
return vars.liquidity;
}
// ParaRouter
function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) {
noFees(tokenA, tokenB);
(amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
address pair = ParaLibrary.pairFor(factory, tokenA, tokenB);
TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA);
TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB);
liquidity = IParaPair(pair).mint(to);
FeesOn(tokenA, tokenB);
}
// TransferHelper
function safeTransferFrom(
address token,
address from,
address to,
uint256 value
) internal {
// bytes4(keccak256(bytes('transferFrom(address,address,uint256)')));
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(0x23b872dd, from, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), 'TransferHelper: TRANSFER_FROM_FAILED');
}
而攻擊點就在於上圖框選出來的 safeTransferFrom
函式中。展開來看,UBT 在其 transferFrom
函式中呼叫了 MasterChef.deposit
函式。
從輸入可以得知,UBT 合約的transferFrom
函式通過呼叫 MasterChef.deposit
將其持有的 USDT-BUSD 的 LP(pid = 18) 全部存入。然後 _deposit
函式記錄 UBT 合約(0xca2)存入了 LP 代幣。【第一次記錄】
相關的 deposit 程式碼如下
// MasterChef
function deposit(uint256 _pid, uint256 _amount) external {
depositInternal(_pid, _amount, msg.sender, msg.sender);
}
function depositInternal(uint256 _pid, uint256 _amount, address _user, address payer) internal {
PoolInfo storage pool = poolInfo[_pid];
pool.lpToken.safeTransferFrom(
address(payer),
address(this),
_amount
);
if (address(pool.ticket) != address(0)) {
UserInfo storage user = userInfo[_pid][_user];
uint256 new_amount = user.amount.add(_amount);
uint256 user_ticket_count = pool.ticket.tokensOfOwner(_user).length;
uint256 staked_ticket_count = ticket_staked_count(_user, address(pool.ticket));
uint256 ticket_level = pool.ticket.level();
(, uint overflow) = check_vip_limit(ticket_level, user_ticket_count + staked_ticket_count, new_amount);
require(overflow == 0, "Exceeding the ticket limit");
deposit_all_tickets(pool.ticket);
}
_deposit(_pid, _amount, _user);
}
function _deposit(uint256 _pid, uint256 _amount, address _user) internal {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][_user];
poolsTotalDeposit[_pid] = poolsTotalDeposit[_pid].add(_amount);
updatePool(_pid);
if (user.amount > 0) {
uint256 pending =
user.amount.mul(pool.accT42PerShare).div(1e12).sub(
user.rewardDebt
);
_claim(pool.pooltype, pending);
}
user.amount = user.amount.add(_amount);
user.rewardDebt = user.amount.mul(pool.accT42PerShare).div(1e12);
emit Deposit(_user, _pid, _amount);
}
完成重入攻擊以後,函式返回到上一級呼叫 MasterChef.addLiquidityInternal
處,滿足了 _lpAddress
對應的流動性代幣增加這一條件。
然後返回到上一級函式, _deposit
函式根據返回的 liquidity
值記錄攻擊者(0x477)存入了 LP 代幣。【第二次記錄】
最後就是將手頭上的 LP 換取等值的 USDT-BUSD 代幣。
UBT 合約先呼叫 MasterChef.withdraw
函式將存入的 LP 取出,然後傳送給攻擊合約(0x477)
攻擊合約同樣先呼叫 MasterChef.withdraw
函式將存入的 LP 取出,然後呼叫 ParaRouter.removeliquidity
將價值 31W 的 USDT-BUSD 取走。
最後,歸還閃電貸的 15W 代幣,然後把剩餘代幣轉賬走。
攻擊流程
先閃電貸貸 15w 的 USDT 和 BUSD,呼叫 ParaRouter.addLiquidity
新增流動性,並將獲得的 LP 轉移到改寫了 transferFrom
函式的 ERC20 標準的 UBT 代幣合約中
然後利用 USDT-BUSD 的 pid 以及 UBT-UGT 代幣,通過 MasterChef.depositByAddLiquidity
新增流動性,內部呼叫 depositByAddLiquidity
→ depositByAddLiquidityInternal
→ addLiquidityInternal
然後 addLiquidityInternal
函式在 paraRouter.addLiquidity
中呼叫 UBT 代幣的 transferFrom
函式進行重入,重入了同合約的 deposit
函式
deposit
函式根據傳入的 pid ,將 UBT 代幣合約中 USDT-BUSD 的 LP 取走,併為 UBT 合約記錄對應的流動性(第一次新增 USDT-BUSD 的 LP)
然後回到 addLiquidityInternal
函式,根據傳入的 pid 獲取對應的(USDT-BUSD) LP 代幣地址,檢測新增流動性前後的 LP 差值,並根據差值給攻擊合約記錄對應的流動性(第二次新增 USDT-BUSD 的 LP)
然後 UBT 合約呼叫 MasterChef.withdraw
函式將存入的 LP 取出,然後傳送給攻擊合約。攻擊合約同樣呼叫 MasterChef.withdraw
函式將存入的 LP 取出,然後呼叫 ParaRouter.removeliquidity
將價值 31W 的 USDT-BUSD 取走。
最後,攻擊者歸還 15w 的閃電貸,並將剩餘的 15W 代幣進行轉移。
總結
本次攻擊事件中被利用的漏洞有兩處:
MasterChef
合約在涉及到流動性操作的時候沒有新增重入鎖,使得攻擊者可以在新增流動性的過程中進行重入攻擊。MasterChef.depositByAddLiquidity
函式中沒有檢測_pid
與_tokens
兩個引數是否為對應關係,這使得攻擊者可以自定義_token
中的函式進行重入攻擊。存入的是_token
,但是計算的是_pid
對應的池子的流動性。