
개요
해당 포스트는 2023년 1월 27일 Dexible에서 일어난 약 1,540,000 달러의 해킹 사건을 분석한 포스트입니다.
컨트랙트 취약점과 해킹 기법, 취약점 수정에 초점을 맞추어 포스트를 작성하였습니다.
취약한 컨트랙트
해킹 대상이 된 컨트랙트는 Dexible로 링크를 통해 확인할 수 있습니다.
취약점을 찾기 위해서는 두 개의 컨트랙트(Dexible.sol, SwapHandler.sol)를 보아야 합니다.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.17;
import "./interfaces/IDexible.sol";
import "./baseContracts/DexibleView.sol";
import "./baseContracts/SwapHandler.sol";
import "./baseContracts/ConfigBase.sol";
contract Dexible is DexibleView, ConfigBase, SwapHandler, IDexible {
event ReceivedFunds(address from, uint amount);
event WithdrewETH(address indexed admin, uint amount);
/*
constructor(DexibleStorage.DexibleConfig memory config) {
configure(config);
}
*/
function initialize(DexibleStorage.DexibleConfig calldata config) public {
configure(config);
}
receive() external payable {
emit ReceivedFunds(msg.sender, msg.value);
}
function swap(SwapTypes.SwapRequest calldata request) external onlyRelay notPaused {
//compute how much gas we have at the outset, plus some gas for loading contract, etc.
uint startGas = gasleft();
SwapMeta memory details = SwapMeta({
feeIsInput: false,
isSelfSwap: false,
startGas: startGas,
preSwapVault: address(DexibleStorage.load().communityVault),
bpsAmount: 0,
gasAmount: 0,
nativeGasAmount: 0,
toProtocol: 0,
toRevshare: 0,
outToTrader: 0,
preDXBLBalance: 0,
outAmount: 0,
inputAmountDue: 0
});
bool success = false;
//execute the swap but catch any problem
try this.fill{
gas: gasleft() - 80_000
}(request, details) returns (SwapMeta memory sd) {
details = sd;
success = true;
} catch {
console.log("Swap failed");
success = false;
}
postFill(request, details, success);
}
function selfSwap(SwapTypes.SelfSwap calldata request) external notPaused {
//we create a swap request that has no affiliate attached and thus no
//automatic discount.
SwapTypes.SwapRequest memory swapReq = SwapTypes.SwapRequest({
executionRequest: ExecutionTypes.ExecutionRequest({
fee: ExecutionTypes.FeeDetails({
feeToken: request.feeToken,
affiliate: address(0),
affiliatePortion: 0
}),
requester: msg.sender
}),
tokenIn: request.tokenIn,
tokenOut: request.tokenOut,
routes: request.routes
});
SwapMeta memory details = SwapMeta({
feeIsInput: false,
isSelfSwap: true,
startGas: 0,
preSwapVault: address(DexibleStorage.load().communityVault),
bpsAmount: 0,
gasAmount: 0,
nativeGasAmount: 0,
toProtocol: 0,
toRevshare: 0,
outToTrader: 0,
preDXBLBalance: 0,
outAmount: 0,
inputAmountDue: 0
});
details = this.fill(swapReq, details);
postFill(swapReq, details, true);
}
function withdraw(uint amount) public onlyAdmin {
address payable rec = payable(msg.sender);
require(rec.send(amount), "Transfer failed");
emit WithdrewETH(msg.sender, amount);
}
}
Dexible.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.17;
import "../interfaces/ISwapHandler.sol";
import "../DexibleStorage.sol";
import "./AdminBase.sol";
import "../../vault/interfaces/ICommunityVault.sol";
import "../LibFees.sol";
import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
abstract contract SwapHandler is AdminBase, ISwapHandler {
using SafeERC20 for IERC20;
struct SwapMeta {
bool feeIsInput;
bool isSelfSwap;
//if a migration occurs during a swap, we don't want to charge
//the trader for the txn if possible
address preSwapVault;
uint startGas;
uint toProtocol;
uint toRevshare;
uint outToTrader;
uint outAmount;
uint bpsAmount;
uint gasAmount;
uint nativeGasAmount;
uint preDXBLBalance;
uint inputAmountDue;
}
function fill(SwapTypes.SwapRequest calldata request, SwapMeta memory meta) external onlySelf returns (SwapMeta memory) {
preCheck(request, meta);
meta.outAmount = request.tokenOut.token.balanceOf(address(this));
for(uint i=0;i<request.routes.length;++i) {
SwapTypes.RouterRequest calldata rr = request.routes[i];
IERC20(rr.routeAmount.token).safeApprove(rr.spender, rr.routeAmount.amount);
(bool s, ) = rr.router.call(rr.routerData);
if(!s) {
revert("Failed to swap");
}
}
uint out = request.tokenOut.token.balanceOf(address(this));
if(meta.outAmount < out) {
meta.outAmount = out - meta.outAmount;
} else {
meta.outAmount = 0;
}
console.log("Expected", request.tokenOut.amount, "Received", meta.outAmount);
//first, make sure enough output was generated
require(meta.outAmount >= request.tokenOut.amount, "Insufficient output generated");
return meta;
}
function postFill(SwapTypes.SwapRequest memory request, SwapMeta memory meta, bool success) internal {
if(success) {
//if we succeeded, then do successful post-swap ops
handleSwapSuccess(request, meta);
} else {
//otherwise, handle as a failure
handleSwapFailure(request, meta);
}
//pay the relayer their gas fee if we have funds for it
payRelayGas(meta.nativeGasAmount);
}
/**
* When a relay-based swap fails, we need to account for failure gas fees if the input
* token is the fee token. That's what this function does
*/
function handleSwapFailure(SwapTypes.SwapRequest memory request, SwapMeta memory meta) internal {
DexibleStorage.DexibleData storage dd = DexibleStorage.load();
uint gasInFeeToken = 0;
if(meta.feeIsInput) {
unchecked {
//the total gas used thus far plus some post-op stuff that needs to get done
uint totalGas = (meta.startGas - gasleft());
console.log("Estimated gas used for failed gas payment", totalGas);
meta.nativeGasAmount = LibFees.computeGasCost(totalGas, false);
}
gasInFeeToken = dd.communityVault.convertGasToFeeToken(address(request.executionRequest.fee.feeToken), meta.nativeGasAmount);
//console.log("Transferring partial input token to devteam for failure gas fees");
//console.log("Failed gas fee", gasInFeeToken);
//transfer input assets from trader to treasury. Recall that any previous transfer amount
//to this contract was rolled back on failure, so we transfer the funds for gas only
request.executionRequest.fee.feeToken.safeTransferFrom(request.executionRequest.requester, dd.treasury, gasInFeeToken);
}
emit SwapFailed(request.executionRequest.requester, address(request.executionRequest.fee.feeToken), gasInFeeToken);
}
/**
* This is called when a relay-based swap is successful. It basically rewards DXBL tokens
* to trader and pays appropriate fees.
*/
function handleSwapSuccess(SwapTypes.SwapRequest memory request,
SwapMeta memory meta) internal {
//reward trader with DXBL tokens
collectDXBL(request, meta.feeIsInput, meta.outAmount);
//pay fees
payAndDistribute(request, meta);
}
/**
* Reward DXBL to the trader
*/
function collectDXBL(SwapTypes.SwapRequest memory request, bool feeIsInput, uint outAmount) internal {
DexibleStorage.DexibleData storage dd = DexibleStorage.load();
uint value = 0;
if(feeIsInput) {
//when input, the total input amount is used to determine reward rate
value = request.tokenIn.amount;
} else {
//otherwise, it's the output generated from the swap
value = outAmount;
}
//Dexible is the only one allowed to ask the vault to mint tokens on behalf of a trader
//See RevshareVault for logic of minting rewards
//NOTE: a migration to a new vault could occur as part of this call. It would just
//change the address of the vault in storage and all proceeds would be forwarded to
//the new vault address. All minting occurs before the migration so mint rates and
//token balances are all forwarded to the new vault as part of the migration. It is
//possible, however, that gas estimates would not account for the migration.
dd.communityVault.rewardTrader(request.executionRequest.requester, address(request.executionRequest.fee.feeToken), value);
}
/**
* Distribute payments to revshare pool, affiliates, treasury, and trader
*/
function payAndDistribute(SwapTypes.SwapRequest memory request,
SwapMeta memory meta) internal {
allocateRevshareAndAffiliate(request, meta);
payProtocolAndTrader(request, meta);
}
/**
* Allocate bps portions to revshare pool and any associated affiliate
*/
function allocateRevshareAndAffiliate(SwapTypes.SwapRequest memory request,
SwapMeta memory meta) internal view {
DexibleStorage.DexibleData storage dd = DexibleStorage.load();
//assume trader gets all output
meta.outToTrader = meta.outAmount;
//the bps portion of fee.
meta.bpsAmount = computeBpsFee(request, meta.feeIsInput, meta.preDXBLBalance, meta.outAmount);
//console.log("Total bps fee", payments.bpsAmount);
uint minFee = LibFees.computeMinFeeUnits(address(request.executionRequest.fee.feeToken));
if(minFee > meta.bpsAmount) {
//console.log("Trade too small. Charging minimum flat fee", minFee);
meta.bpsAmount = minFee;
}
//revshare pool gets portion of bps fee collected
meta.toRevshare = (meta.bpsAmount * dd.revshareSplitRatio) / 100;
//console.log("To revshare", meta.toRevshare);
//protocol gets remaining bps but affiliate fees come out of its portion. Will revert if
//Dexible miscalculated the affiliate reward portion. However, the call would revert here and
//Dexible relay would pay the gas fee for its mistake. Self-swap has no affiliate so no revert
//would happen.
require(request.executionRequest.fee.affiliatePortion < meta.bpsAmount-meta.toRevshare, "Miscalculated affiliate portion");
meta.toProtocol = (meta.bpsAmount-meta.toRevshare) - request.executionRequest.fee.affiliatePortion;
//console.log("Protocol pre-gas", meta.toProtocol);
//fees accounted for thus far
uint total = meta.toRevshare + meta.toProtocol + request.executionRequest.fee.affiliatePortion;
if(!meta.feeIsInput) {
//this is an interim calculation. Gas fees get deducted later as well. This will
//also revert if insufficient output was generated to cover all fees
//console.log("Out amount", meta.outAmount, "Total fees so far", total);
if(meta.outAmount < total) {
revert(
string(
abi.encodePacked(
_concatUintString("Insufficient output to pay bps fees. Required: ", total),
_concatUintString(" Output amount: ", meta.outAmount)
)
)
);
}
meta.outToTrader = meta.outAmount - total;
} else {
//input debits are handled later so we keep track of what's due so far in bps fees
meta.inputAmountDue = total;
}
}
/**
* Final step to compute gas consumption for trader and pay the vault, protocol, affiliate, and trader
* their portions.
*/
function payProtocolAndTrader(SwapTypes.SwapRequest memory request,
SwapMeta memory meta) internal {
DexibleStorage.DexibleData storage dd = DexibleStorage.load();
if(!meta.isSelfSwap) {
//If this was a relay-based swap, we need to pay treasury an estimated gas fee
//we leave unguarded for gas savings since we know start gas is always higher
//than used and will never rollover without costing an extremely large amount of $$
unchecked {
//console.log("Start gas", meta.startGas, "Left", gasleft());
//the total gas used thus far plus some post-op buffer for transfers and events
uint totalGas = (meta.startGas - gasleft());
if(address(dd.communityVault) != meta.preSwapVault && totalGas > 200_000) {
totalGas -= 200_000; //give credit for estimated migration gas
}
console.log("Estimated gas used for trader gas payment", totalGas);
meta.nativeGasAmount = LibFees.computeGasCost(totalGas, true); //(totalGas * tx.gasprice);
}
//use price oracle in vault to get native price in fee token
meta.gasAmount = dd.communityVault.convertGasToFeeToken(address(request.executionRequest.fee.feeToken), meta.nativeGasAmount);
//console.log("Gas paid by trader in fee token", meta.gasAmount);
//add gas payment to treasury portion
meta.toProtocol += meta.gasAmount;
//console.log("Payment to protocol", meta.toProtocol);
if(!meta.feeIsInput) {
//if output was fee, deduct gas payment from proceeds, revert if there isn't enough output
//for it (should have been caught offchain before submit). We make sure the trader gets
//something out of the deal by ensuring output is more than gas.
if(meta.outToTrader <= meta.gasAmount) {
revert(
string(
abi.encodePacked(
_concatUintString("Insufficient output to pay gas fees. Required: ", meta.gasAmount),
_concatUintString(" Trader output proceeds: ", meta.outToTrader)
)
)
);
}
meta.outToTrader -= meta.gasAmount;
} else {
//other make sure it's account for as input debit
meta.inputAmountDue += meta.gasAmount;
}
//console.log("Proceeds to trader", payments.outToTrader);
}
//now distribute fees
IERC20 feeToken = request.executionRequest.fee.feeToken;
if(meta.feeIsInput) {
//make sure we didn't overspend on trading input amount and not have enough to cover
//fees
uint totalInputSpent = request.routes[0].routeAmount.amount + meta.inputAmountDue;
//console.log("Total input spent", totalInputSpent, "Expected input amount", request.tokenIn.amount);
if(totalInputSpent > request.tokenIn.amount) {
revert(
string(
abi.encodePacked(_concatUintString("Attempt to spend more input than anticipated. Total required: ", totalInputSpent),
_concatUintString(" Max input: ", request.tokenIn.amount))
)
);
}
//pay protocol from input token
feeToken.safeTransferFrom(request.executionRequest.requester, dd.treasury, meta.toProtocol);
//pay vault from input token
feeToken.safeTransferFrom(request.executionRequest.requester, address(dd.communityVault), meta.toRevshare);
if(request.executionRequest.fee.affiliatePortion > 0) {
//pay affiliate their portion which was deducted from protocol's bps portion
feeToken.safeTransferFrom(request.executionRequest.requester, request.executionRequest.fee.affiliate, request.executionRequest.fee.affiliatePortion);
emit AffiliatePaid(request.executionRequest.fee.affiliate, address(feeToken), request.executionRequest.fee.affiliatePortion);
}
} else {
//otherwise, transfer directly from generated output
//console.log("Total output spent", (meta.toProtocol + meta.toRevshare + request.executionRequest.fee.affiliatePortion));
//console.log("Total output generated", meta.outAmount);
feeToken.safeTransfer(dd.treasury, meta.toProtocol);
feeToken.safeTransfer(address(dd.communityVault), meta.toRevshare);
if(request.executionRequest.fee.affiliatePortion > 0) {
//pay affiliate their portion
feeToken.safeTransfer(request.executionRequest.fee.affiliate, request.executionRequest.fee.affiliatePortion);
emit AffiliatePaid(request.executionRequest.fee.affiliate, address(feeToken), request.executionRequest.fee.affiliatePortion);
}
}
//and send trader their proceeds
request.tokenOut.token.safeTransfer(request.executionRequest.requester, meta.outToTrader);
emit SwapSuccess(request.executionRequest.requester,
request.executionRequest.fee.affiliate,
request.tokenOut.amount,
meta.outToTrader,
address(request.executionRequest.fee.feeToken),
meta.gasAmount,
request.executionRequest.fee.affiliatePortion,
meta.bpsAmount);
}
function preCheck(SwapTypes.SwapRequest calldata request, SwapMeta memory meta) internal {
//make sure fee token is allowed
address fToken = address(request.executionRequest.fee.feeToken);
DexibleStorage.DexibleData storage dd = DexibleStorage.load();
require(
dd.communityVault.isFeeTokenAllowed(fToken),
"Fee token is not allowed"
);
//and that it's one of the tokens swapped
require(fToken == address(request.tokenIn.token) ||
fToken == address(request.tokenOut.token),
"Fee token must be input or output token");
//get the current DXBL balance at the start to apply discounts
meta.preDXBLBalance = dd.dxblToken.balanceOf(request.executionRequest.requester);
//flag whether the input token is the fee token
meta.feeIsInput = address(request.tokenIn.token) == address(request.executionRequest.fee.feeToken);
//transfer input tokens for router so it can perform swap
//console.log("Transfering input for trading:", request.routes[0].routeAmount.amount);
request.tokenIn.token.safeTransferFrom(request.executionRequest.requester, address(this), request.routes[0].routeAmount.amount);
//console.log("Expected output", request.tokenOut.amount);
}
/**
* Pay the relay with gas funds stored in this contract. The gas used provided
* does not include arbitrum multiplier but may include additional amount for post-op
* gas estimates.
*/
function payRelayGas(uint gasFee) internal {
if(gasFee == 0) {
return;
}
//console.log("Relay Gas Reimbursement", gasFee);
//if there is ETH in the contract, reimburse the relay that called the fill function
if(address(this).balance < gasFee) {
//console.log("Cannot reimburse relay since do not have enough funds");
emit InsufficientGasFunds(msg.sender, gasFee);
} else {
//console.log("Transfering gas fee to relay");
payable(msg.sender).transfer(gasFee);
emit PaidGasFunds(msg.sender, gasFee);
}
}
/**
* Compute the bps to charge for the swap. This leverages the DXBL token to compute discounts
* based on trader balances and discount rates applied per DXBL token.
*/
function computeBpsFee(SwapTypes.SwapRequest memory request, bool feeIsInput, uint preDXBL, uint outAmount) internal view returns (uint) {
//apply any discounts
DexibleStorage.DexibleData storage ds = DexibleStorage.load();
return ds.dxblToken.computeDiscountedFee(
IDXBL.FeeRequest({
trader: request.executionRequest.requester,
amt: feeIsInput ? request.tokenIn.amount : outAmount,
referred: request.executionRequest.fee.affiliate != address(0),
dxblBalance: preDXBL,
stdBpsRate: ds.stdBpsRate,
minBpsRate: ds.minBpsRate
}));
}
function _concatUintString(string memory s, uint val) private pure returns(string memory) {
return string(abi.encodePacked(s, Strings.toString(val)));
}
}
SwapHandler.sol
컨트랙트 기능 분석
더보기
해당 컨트랙트의 취약점을 분석하기 위해서는 2개의 함수를 보아야 합니다.
selfSwap 함수
function selfSwap(SwapTypes.SelfSwap calldata request) external notPaused {
//we create a swap request that has no affiliate attached and thus no
//automatic discount.
SwapTypes.SwapRequest memory swapReq = SwapTypes.SwapRequest({
executionRequest: ExecutionTypes.ExecutionRequest({
fee: ExecutionTypes.FeeDetails({
feeToken: request.feeToken,
affiliate: address(0),
affiliatePortion: 0
}),
requester: msg.sender
}),
tokenIn: request.tokenIn,
tokenOut: request.tokenOut,
routes: request.routes
});
SwapMeta memory details = SwapMeta({
feeIsInput: false,
isSelfSwap: true,
startGas: 0,
preSwapVault: address(DexibleStorage.load().communityVault),
bpsAmount: 0,
gasAmount: 0,
nativeGasAmount: 0,
toProtocol: 0,
toRevshare: 0,
outToTrader: 0,
preDXBLBalance: 0,
outAmount: 0,
inputAmountDue: 0
});
details = this.fill(swapReq, details);
postFill(swapReq, details, true);
}
selfSwap 함수는 입력한 데이터들을 파싱하여 fill 함수의 인자로 넣어주는 간단한 함수입니다.
fill 함수
function fill(SwapTypes.SwapRequest calldata request, SwapMeta memory meta) external onlySelf returns (SwapMeta memory) {
preCheck(request, meta);
meta.outAmount = request.tokenOut.token.balanceOf(address(this));
for(uint i=0;i<request.routes.length;++i) {
SwapTypes.RouterRequest calldata rr = request.routes[i];
IERC20(rr.routeAmount.token).safeApprove(rr.spender, rr.routeAmount.amount);
(bool s, ) = rr.router.call(rr.routerData);
if(!s) {
revert("Failed to swap");
}
}
uint out = request.tokenOut.token.balanceOf(address(this));
if(meta.outAmount < out) {
meta.outAmount = out - meta.outAmount;
} else {
meta.outAmount = 0;
}
console.log("Expected", request.tokenOut.amount, "Received", meta.outAmount);
//first, make sure enough output was generated
require(meta.outAmount >= request.tokenOut.amount, "Insufficient output generated");
return meta;
}
selfSwap 함수에서 파싱한 데이터를 가지고 실제 행위를 수행하는 함수입니다.
onlySelf modifier를 활용하여 selfSwap 함수 등 내부를 통해서만 호출될 수 있도록 하였으며,
preCheck 함수를 통해 request 내 들어있는 토큰의 유효성을 검증합니다.
그 후 router 컨트랙트를 통해 행위를 수행하게 됩니다.
취약점 분석
더보기
취약점은 fill 함수에서 rr.router를 검증하지 않아 발생하게 됩니다.
(bool s, ) = rr.router.call(rr.routerData);
이 부분의 인자를 잘 조절하여 아래의 함수호출이 가능하게 됩니다.
// rr.router = token
token.transfrom(victim_address, attacker, amount)
Dexible 컨트랙트의 경우 swapRouter기능도 가지고 있기 때문에, 일반 Dexible에다가 높은 allowance를 걸어놓은 사람이 많고, 해당 공격을 통해 모든 토큰을 탈취할 수 있습니다.
'Blockchain-Hacking > Hacking-analysis' 카테고리의 다른 글
| LaunchZone(LZ finance) Hacking Analysis (0) | 2023.03.05 |
|---|---|
| Hakuna Matata($TATA) Hacking Analysis (0) | 2023.03.03 |
| Dynamic Finance($DYNA) Hacking Analysis (0) | 2023.02.25 |