本文讨论了Gains Network的一个分叉存在的两个严重漏洞,这些漏洞可能导致用户从流动性池中丢失资金。文章详细介绍了Gains的工作原理,以及如何利用这些漏洞进行高达900%的交易利润,最后提及了相关的补救措施和修复方案。
在近期对 Gains Network 的一个分叉进行探索时,我们发现了两个可能导致流动性池资金损失的关键问题。这些问题允许用户创建交易,使他们每次交易都能获利 900%,无论交易初始化后代币的价格如何。尽管当前版本的 Gains 不包含这些问题,但仍有许多使用旧版本的 Gains 项目的分叉可能存在这些问题。第一个问题最初并不存在于 Gains Network 中,而是在我们审核的分叉中引入的;而本文中的第二个问题则存在于 Gains Network 的旧版本中。
在本文中,我们将讨论这些问题并研究其潜在影响。
在深入了解这些问题的具体细节之前,让我们首先了解一下 Gains 是如何运作的。
Gains 是一个去中心化的杠杆交易平台。杠杆交易平台允许用户使用较少的初始资金获取对基础资产更大交易头寸的敞口。实质上,用户可以从流动性池借入资金以进行更大的交易。这有可能将他们的收益(或损失)增加数倍。
让我们首先熟悉一些在杠杆交易中使用的概念。
抵押品 — 交易者愿意投入到交易中的资产数量。
杠杆 — 杠杆使交易者能够通过从流动性池借入资金来放大潜在收益(或损失)。例如,考虑一个投资 1,000 美元的交易者,他想要做多 BTC。如果没有杠杆,他们只会从这 1,000 美元的投资中获利。然而,使用 10 倍杠杆,他们可以将其 1,000 美元的投资转化为 10,000 美元的头寸。这意味着 BTC 的任何价格变动对他们的交易将产生 10 倍的影响。
止损 — 止损(SL)订单是一种特殊类型的订单,用于限制开放头寸可能产生的损失。例如,为多头头寸设置一个低于当前价格 10% 的止损,将在价格跌破该值时关闭交易,从而限制损失。
止盈 — 止盈(TP)订单允许交易者在达到某一盈利水平时自动平仓。例如,为多头头寸设置一个高于当前价格 20% 的止盈订单,将在价格达到这一门槛时关闭交易。此功能使交易者能够从交易中锁定利润。
考虑到这些概念,我们可以深入了解用户在 Gains 分叉中下单和交易的流程。
用户可以下三种类型的订单,无论是 买入(长) 还是 卖出(短):
市场(MARKET)
— 当交易者希望立即开启交易时使用。交易按市场价格加上点差打开。
限价(LIMIT)
— 当交易者希望在低于当前价格时进场做多,或在高于当前价格时进场做空时使用。
止损限价(STOP LIMIT)
— 当交易者希望在超出当前价格时做多,或在低于当前价格时做空时使用。
Gains 分叉允许用户使用 openTrade
函数进行交易,该函数接收以下参数,
function openTrade(
ITradingStorage.Trade calldata t,
IExecute.OpenLimitOrderType _type,
uint _slippageP,
bytes[] calldata priceUpdateData,
uint _executionFee // 以 USDC 表示,限价订单的可选项
) external payable onlyWhitelist whenNotPaused {
其中 Trade
结构定义如下:
struct Trade{
address trader;
uint pairIndex;
uint index;
uint initialPosToken;
uint positionSizeUSDC;
uint openPrice;
bool buy;
uint leverage;
uint tp;
uint sl;
uint timestamp;
}
而 OpenLimitOrderType
枚举定义如下:
enum OpenLimitOrderType {
MARKET,
REVERSAL, // 止损限价
MOMENTUM // 限价
}
用户可以打开 MARKET
、REVERSAL
或 MOMENTUM
类型的订单。MARKET
订单在同一交易中完成,而其他两种订单作为待处理限价订单存储,openPrice
设置为待处理限价订单的 minPrice
和 maxPrice
。任何人都可以通过调用 executeLimitOrder
函数来执行这些待处理限价订单,并以此换取在 openTrade
函数中设置的 _executionFee
。
openTrade
和 executeLimitOrder
函数调用 PriceAggregator
的 fulfill
函数,该函数验证价格是否正确,然后调用 TradingCallback
合约中的相应函数,该函数负责开启和关闭交易。
在分析 Trade
结构以及用于关闭不同交易类型的价格时,我们碰到了第一个关键问题。
openPrice
现在我们已经了解了 Gains 的内部运作,让我们开始分析第一个问题。如上所示,Trade
结构有一些不同的字段,包括 tp
和 sl
,它们分别指用户希望为交易设置的止盈和止损价格。当设置这些值时,如果满足相应的价格阈值,交易可以使用订单类型 LimitOrder.TP
和 LimitOrder.SL
关闭。
例如,假设在 BTC/USD 对上以 openPrice
为 $1,000 开启了一笔交易,止损设置为 $900。如果 BTC 相对于 USD 的价格达到 $900,则该交易可以作为 LimitOrder.SL
订单关闭。要关闭此交易,任何用户都可以使用以下参数调用 executeLimitOrder
,
function executeLimitOrder(
ITradingStorage.LimitOrder _orderType,
address _trader, // 交易者的地址
uint _pairIndex, // 交易对的索引
uint _index, // 订单的索引
bytes[] calldata priceUpdateData // Pyth 价格更新数据
) external payable onlyWhitelist whenNotPaused {
其中 _orderType
可以是以下之一:
enum LimitOrder{
TP,
SL,
LIQ,
OPEN
}
在验证 liqPrice
(清算价格)未高于买入(多头)订单的 sl
之后 —— 卖出(空头)订单则相反,该函数调用 PriceAggregator
合约中的 fulfill
函数验证 priceUpdateData
是否正确。然后 fulfill
调用 TradingCallback
合约中的 executeLimitCloseOrderCallback
来关闭交易。executeLimitCloseOrderCallback
函数计算交易的利润百分比(盈利为正,亏损为负),扣除一些费用,将剩余代币转账到交易中,并注销交易。利润百分比计算如下:
v.price = aggregator.pairsStorage().guaranteedSlEnabled(t.pairIndex)
? o.orderType == ITradingStorage.LimitOrder.TP ? t.tp : o.orderType == ITradingStorage.LimitOrder.SL
? t.sl
: a.price
: a.price;
v.profitP = _currentPercentProfit(t.openPrice, v.price, t.buy, t.leverage);
//...
function _currentPercentProfit(
uint openPrice,
uint currentPrice,
bool buy,
uint leverage
) private pure returns (int p) {
int diff = buy ? (int(currentPrice) - int(openPrice)) : (int(openPrice) - int(currentPrice));
int minPnlP = int(_PRECISION) * (-100);
int maxPnlP = int(_MAX_GAIN_P) * int(_PRECISION);
p = (diff * 100 * int(_PRECISION.mul(leverage))) / int(openPrice);
p = p < minPnlP ? minPnlP : p > maxPnlP ? maxPnlP : p;
}
假设一个交易者在 openPrice
为 $1,000、杠杆为 5 倍的情况下开启了一笔交易,并将 SL 设置为 $900,如果价格跌破 $900,_currentPercentProfit
将返回 -50
,表示该交易的损失为初始抵押品存入的 50%。在此计算中,_currentPercentProfit
中的 diff
被计算为 currentPrice
(在此情况下是 t.sl)和 openPrice
之间的差值。
在这里,我们可以问一个问题:如果 t.sl
(或 currentPrice
)可以设置为高于 openPrice
呢?
在分析代码时,我们也提出了同样的问题。我们最初的假设是,对于买入订单,无法做到这一点,但在进一步分析中,我们发现了一种方式来进行此类交易,这正是 bug 存在的地方。如果 t.sl
大于 openPrice
是可能的,对于买入订单,diff
的值将为正,即便代币在初始买入交易后价格下跌,这将意味着交易者净赚。openTrade
函数中有一个检查,防止 t.sl
高于 t.openPrice
:
function openTrade(
ITradingStorage.Trade calldata t,
IExecute.OpenLimitOrderType _type,
uint _slippageP,
bytes[] calldata priceUpdateData,
uint _executionFee // 在 USDC 中表示,限价订单的可选项
) external payable onlyWhitelist whenNotPaused {
//...
require(t.tp == 0 || (t.buy ? t.tp > t.openPrice : t.tp < t.openPrice), "WRONG_TP");
require(t.sl == 0 || (t.buy ? t.sl < t.openPrice : t.sl > t.openPrice), "WRONG_SL");
//...
if (_type != IExecute.OpenLimitOrderType.MARKET) {
uint index = storageT.firstEmptyOpenLimitIndex(msg.sender, t.pairIndex);
storageT.storeOpenLimitOrder(
ITradingStorage.OpenLimitOrder(
msg.sender,
t.pairIndex,
index,
t.positionSizeUSDC,
t.buy,
t.leverage,
t.tp,
t.sl,
t.openPrice,
t.openPrice,
block.number,
_executionFee
)
);
aggregator.executions().setOpenLimitOrderType(msg.sender, t.pairIndex, index, _type);
//...
这可以通过将 t.openPrice
设置为略高于 t.sl
来绕过,该操作将订单存储为开放限价订单,并将 minPrice
和 maxPrice
设置为 t.openPrice
。接下来,如果该订单以类型 ITradingStorage.LimitOrder.MOMENTUM
开盘,则可以通过调用 executeLimitOrder
来完成该订单。MOMENTUM 订单类型是必要的,因为它绕过了 executeLimitOrder
回调中的以下检查:
function executeLimitOpenOrderCallback(AggregatorAnswer memory a) external override onlyPriceAggregator {
//...
IExecute.OpenLimitOrderType t = executor.openLimitOrderTypes(n.trader, n.pairIndex, n.index);
IPriceAggregator aggregator = storageT.priceAggregator();
IPairStorage pairsStored = aggregator.pairsStorage();
//...
if (
t == IExecute.OpenLimitOrderType.MARKET
? (a.price >= o.minPrice && a.price <= o.maxPrice)
: (
t == IExecute.OpenLimitOrderType.REVERSAL
? (o.buy ? a.price >= o.maxPrice : a.price <= o.minPrice)
: (o.buy ? a.price <= o.maxPrice : a.price >= o.minPrice) // 检查
) && _withinExposureLimits(o.trader, o.pairIndex, o.positionSize.mul(o.leverage))
) {
ITradingStorage.Trade memory finalTrade = _registerTrade(
ITradingStorage.Trade(
o.trader,
o.pairIndex,
0,
0,
o.positionSize,
a.price,
o.buy,
o.leverage,
o.tp,
o.sl,
0
)
);
回调函数将此交易记录为 openPrice
为 a.price
(在价格影响后的当前代币价格)。这样做将开启一笔 t.sl
大于 t.openPrice
的交易,因此该交易可以获利(在这种情况下的最大利润为 900%)。以下是漏洞的工作原理示例:
开始
交易者之前的余额:10000000000
交易者以较大的开盘价和止损刚好低于该值下限下限订单。以下是一个示例交易:
trade.trader = _trader;
trade.pairIndex = _pairIndex;
trade.index = _index;
trade.initialPosToken = 0;
trade.positionSizeUSDC = _amount;
trade.openPrice = 100000e10;
trade.buy = true;
trade.leverage = 10e10;
trade.tp = 0;
trade.sl = 100000e10-1;
trade.timestamp = block.number;
当该开盘订单被执行时,开盘价将变为当前价格 + 价格影响:
多头订单的开盘价 505252500000000
多头订单的止损 999999999999999
止损立即被执行,因为止损的值大于当前价格。
交易者之后的余额:97911000000
如上所示,交易者的初始余额为 10000000000
,并使用该金额下了一笔交易。该交易的下单方式为止损高于开盘价。当交易被突击交易类型 LimitOrder.SL
关闭时,该交易获得了即时的 900% 利润(扣除了一些交易费用)。因此,该问题允许交易在交易初始化后无论代币价格变化如何都获得即时的 900% 利润。
导致漏洞的两个潜在问题是:
a.price
作为两种限价订单类型的 openPrice
。SL
,即便清算价格高于该订单的止损。这是因为该分叉允许用户执行这些订单,而不是验证订单类型后再执行的 NFT 机器人。要检查你的 Gains 分叉中是否存在此问题,请确认在 executeLimitOpenOrderCallback
函数中的 _registerTrade
调用中,针对 REVERSAL
和 MOMENTUM
类型的订单是否使用了 a.price
。
如果协议允许用户触发此类订单,确保将 LIQ 订单优先于任何订单类型;例如,确保在 NFT 机器人中优先选择 LIQ 订单类型而不是止损。
以下是 Gains 当前是如何 使用正确价格↗ 作为不同类型订单的 openPrice
。
int
让我们再看一下 _currentPercentProfit
函数,专注于该函数中发生的 int
转换:
function _currentPercentProfit(
uint openPrice,
uint currentPrice,
bool buy,
uint leverage
) private pure returns (int p) {
int diff = buy ? (int(currentPrice) - int(openPrice)) : (int(openPrice) - int(currentPrice));
int minPnlP = int(_PRECISION) * (-100);
int maxPnlP = int(_MAX_GAIN_P) * int(_PRECISION);
p = (diff * 100 * int(_PRECISION.mul(leverage))) / int(openPrice);
p = p < minPnlP ? minPnlP : p > maxPnlP ? maxPnlP : p;
}
openPrice
的值被设为 a.price
,这是价格在价格影响后的代币价格。此值不能大于 type(int256).max
,因此此类型转换是安全的,但 currentPrice
呢?如前所述,currentPrice
的值来源于 t.sl
或 t.tp
,这些值可能由用户设置。因此,这些值可以设置为非常大的整数,在转换为 int
时会变为负数。
让我们考虑一个卖出订单,其中 currentPrice
的值为 type(uint256).max
。此时计算获得的 diff
结果将会是 openPrice + 1
( int(type(uint256).max)
= -1),因此利润百分比几乎等于 100 * 杠杆。因此,如果杠杆大于9,该函数将返回900%的利润。
由于我们需要将 currentPrice
(t.tp 或 t.sl)设置为 type(uint256).max
且订单必须为卖出类型,唯一可以绕过以下检查的情况是当 t.tp
设置为 type(uint256).max
。
function executeLimitCloseOrderCallback(AggregatorAnswer memory a) external override onlyPriceAggregator {
//...
v.price = aggregator.pairsStorage().guaranteedSlEnabled(t.pairIndex)
? o.orderType == ITradingStorage.LimitOrder.TP ? t.tp : o.orderType == ITradingStorage.LimitOrder.SL
? t.sl
: a.price
: a.price;
v.profitP = _currentPercentProfit(t.openPrice, v.price, t.buy, t.leverage);
//...
v.reward = (o.orderType == ITradingStorage.LimitOrder.TP &&
t.tp > 0 &&
(t.buy ? a.price >= t.tp : a.price <= t.tp)) ||
(o.orderType == ITradingStorage.LimitOrder.SL &&
t.sl > 0 &&
(t.buy ? a.price <= t.sl : a.price >= t.sl))
? (v.posToken.mul(t.leverage) * aggregator.pairsStorage().pairLimitOrderFeeP(t.pairIndex)) /
100 /
_PRECISION
: 0;
}
//...
止盈可以很容易地通过 updateTp
设置为 type(uint256).max
,该函数直接更新 t.tp
的值而不进行任何额外检查:
function updateTp(uint _pairIndex, uint _index, uint _newTp) external onlyWhitelist whenNotPaused {
_updateTp(_pairIndex, _index, _newTp);
}
function _updateTp(uint _pairIndex, uint _index, uint _newTp) internal {
uint leverage = storageT.openTrades(msg.sender, _pairIndex, _index).leverage;
uint tpLastUpdated = storageT.openTradesInfo(msg.sender, _pairIndex, _index).tpLastUpdated;
require(leverage > 0, "NO_TRADE");
require(block.number - tpLastUpdated >= limitOrdersTimelock, "LIMIT_TIMELOCK");
storageT.updateTp(msg.sender, _pairIndex, _index, _newTp);
emit TpUpdated(msg.sender, _pairIndex, _index, _newTp, block.timestamp);
}
以下是此漏洞的工作原理示例:
开始
交易者之前的余额:1000000000
交易者创建一个空头头寸:
trade.trader = _trader;
trade.pairIndex = _pairIndex;
trade.index = _index;
trade.initialPosToken = 0;
trade.positionSizeUSDC = _amount;
trade.openPrice = _price;
trade.buy = true;
trade.leverage = 10e10;
trade.tp = 0;
trade.sl = 0;
trade.timestamp = block.number;
交易者调用 `updateTp`,将 `_newTp` 设置为 `0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff`
当类型为 `LimitOrder.TP` 的限制关闭被调用时,交易者获得 900% 的利润。
交易者之后的余额:9791100000
在这里,交易者的初始余额为 1000000000
,并与之前的问题相似获得了 900% 的利润。这两个问题都是关键的,因为它们使交易能够获得 9 倍利润,无论代币的价格波动如何。如果没有其他安全措施,这可能允许攻击者从流动性池中窃取所有资金。
此处潜在问题在于将 uint
转换为 int
。如果 updateTp
函数允许将任何 uint256
设置为 TP 价格,并且在 executeLimitCloseOrderCallback
函数中没有对 TP 进行其他限制检查,你所使用的 Gains 分叉的版本可能会受到此漏洞的影响。
快速修复此问题的方法是确保在 updateTp
函数中不允许任何大于 type(int256).max
的数字被设置为 TP 价格。如果你没有在 updateSl
中实现回调进行范围检查,类似的检查也应该适用于 updateSl
函数。
Gains Network 通过在交易关闭时验证 sl
和 tp
的值来减轻这些问题。这些值会被验证为自上次更新块以来的代币价格高低范围内。如果这些值不在该范围内,则使用当前市场价格作为 SL 或 TP 价格。以下是相关代码:
v.price = o.orderType == IGNSTradingStorage.LimitOrder.TP
? t.tp
: (o.orderType == IGNSTradingStorage.LimitOrder.SL ? t.sl : v.liqPrice);
v.exactExecution = v.price > 0 && a.low <= v.price && a.high >= v.price;
if (v.exactExecution) {
v.reward1 = o.orderType == IGNSTradingStorage.LimitOrder.LIQ
? (v.posDai * 5) / 100
: (v.levPosDai * multiCollatDiamond.pairNftLimitOrderFeeP(t.pairIndex)) / 100 / PRECISION;
} else {
v.price = a.open;
在此处,只有当 sl
或 tp
在正确范围内时,v.exactExecution
值才为真,否则 a.open
被用作 sl
或 tp
的价格。因此,无法为这些变量设置不正确的值,基本上修复了这些漏洞。
我们已在 Gains 的旧版本中发现了两个关键问题,这可能导致协议资金损失。尽管我们已联系到所有找到的使用旧版本 Gains 的团队,但仍有团队可能在使用易受攻击的版本。如果你正在基于 Gains 开发并相信自己面临这两个关键问题中的任何一个的风险,请 与我们联系↗。
感谢 Zellic 工程师 Vakzz,他识别了第二个问题,感谢 Kuilin 在我们的审计过程中提供的见解和 invaluable 合作。
Zellic 专注于保护新兴技术。我们的安全研究人员已在从财富 500 强公司到 DeFi 巨头的最有价值目标中发现了漏洞。
开发人员、创始人和投资者信赖我们的安全评估,以快速、自信且无关键漏洞地推出产品。凭借我们在现实世界进攻性安全研究中的背景,我们能发现其他人错过的问题。
与我们联系↗ 进行比其他审计更好的审计。真正的审计,而非流于形式。
- 原文链接: zellic.io/blog/issues-in...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!