Logo
Overview
[CLUB LEAGUE] CIE writeup

[CLUB LEAGUE] CIE writeup

October 11, 2025
5 min read

TL;DR

  • 실행 파일 cie는 입력 PNG를 읽어 자체 포맷(HSPACE)로 인코딩한다.
  • flag.out은 해당 자체 포맷으로 인코딩되어있다.
  • 최종 목표는 원본 이미지를 복원해 플래그를 추출하는 것이다.

Analysis

ELF

1. PNG stream load

_BOOL8 __fastcall sub_1CE90(__int64 *a1, __int64 a2)
{
*a1 = sub_2420(a2, (__int64)(a1 + 1), (__int64)a1 + 12, (__int64)(a1 + 2), 3u);
return *a1 != 0;
}
__int64 __fastcall sub_2420(__int64 a1, __int64 a2, __int64 a3, __int64 a4, unsigned int a5)
{
__int64 v6; // [rsp+0h] [rbp-40h]
FILE *stream; // [rsp+8h] [rbp-38h]
stream = (FILE *)sub_24C0(a1, &unk_24680);
if ( stream )
{
v6 = sub_2520(stream, a2, a3, a4, a5);
fclose(stream);
return v6;
}
else
{
sub_24F0("can't fopen");
return 0;
}
}

PNG를 로드하고 픽, 높이, 픽셀 수를 가져옴.

2. 스트림 변환

unsigned __int64 __fastcall sub_1D5B0(_DWORD *a1)
{
unsigned __int64 result; // rax
char v2; // [rsp+5h] [rbp-2Bh]
char v3; // [rsp+6h] [rbp-2Ah]
char v4; // [rsp+7h] [rbp-29h]
char v5; // [rsp+17h] [rbp-19h]
unsigned __int64 i; // [rsp+18h] [rbp-18h]
unsigned __int64 v7; // [rsp+20h] [rbp-10h]
v7 = 3 * a1[3] * a1[2];
sub_1DD80(a1 + 6, v7);
v3 = **(_BYTE **)a1;
*(_BYTE *)sub_1DE20(a1 + 6, 0) = v3;
v4 = *(_BYTE *)(*(_QWORD *)a1 + 1LL);
*(_BYTE *)sub_1DE20(a1 + 6, 1) = v4;
v5 = *(_BYTE *)(*(_QWORD *)a1 + 2LL);
*(_BYTE *)sub_1DE20(a1 + 6, 2) = v5;
for ( i = 3; ; ++i )
{
result = i;
if ( i >= v7 )
break;
v2 = *(_BYTE *)(*(_QWORD *)a1 + i) - *(_BYTE *)(*(_QWORD *)a1 + i - 3);
*(_BYTE *)sub_1DE20(a1 + 6, i) = v2;
}
return result;
}

RGB 스트림을 채널별 누적 델타로 변환한다. 첫 3바이트는 그대로, 이후 값은 같은 채널 이전 픽셀과의 차이

3. Huffman Codes

__int64 __fastcall sub_1D6B0(__int64 a1)
{
_DWORD *v1; // rax
int i; // [rsp+20h] [rbp-100h]
__int64 v4; // [rsp+2Ch] [rbp-F4h]
char v5; // [rsp+37h] [rbp-E9h] BYREF
__int64 v6; // [rsp+38h] [rbp-E8h] BYREF
_QWORD v7[2]; // [rsp+40h] [rbp-E0h] BYREF
char v8; // [rsp+56h] [rbp-CAh]
char v9; // [rsp+57h] [rbp-C9h] BYREF
_BYTE v10[28]; // [rsp+58h] [rbp-C8h] BYREF
_BYTE v11[12]; // [rsp+74h] [rbp-ACh] BYREF
__int64 v12; // [rsp+80h] [rbp-A0h]
__int64 v13; // [rsp+88h] [rbp-98h] BYREF
_QWORD v14[2]; // [rsp+90h] [rbp-90h] BYREF
__int64 v15; // [rsp+A0h] [rbp-80h] BYREF
unsigned __int8 v16; // [rsp+AFh] [rbp-71h]
__int64 v17; // [rsp+B0h] [rbp-70h] BYREF
_QWORD v18[2]; // [rsp+B8h] [rbp-68h] BYREF
char v19; // [rsp+DBh] [rbp-45h] BYREF
int v20; // [rsp+DCh] [rbp-44h] BYREF
_BYTE v21[24]; // [rsp+E0h] [rbp-40h] BYREF
__int64 v22; // [rsp+F8h] [rbp-28h]
char *v23; // [rsp+100h] [rbp-20h]
char *v24; // [rsp+108h] [rbp-18h]
char *v25; // [rsp+118h] [rbp-8h]
v22 = a1;
v20 = 0;
v23 = &v19;
v25 = &v19;
sub_1E830(v21, 256, &v20);
v24 = &v19;
sub_1F720(&v19);
v18[1] = a1 + 24;
v18[0] = sub_1E8B0(a1 + 24);
v17 = sub_1E8E0(a1 + 24);
while ( (sub_1E910(v18, &v17) & 1) != 0 )
{
v16 = *(_BYTE *)sub_1E960(v18);
v1 = (_DWORD *)sub_1E980(v21, v16);
++*v1;
sub_1E9A0(v18);
}
v15 = 0;
sub_1E9C0(a1, v21, &v15);
sub_1ECD0(a1, v15, 0);
v14[1] = a1 + 72;
v14[0] = sub_1ED90(a1 + 72);
v13 = sub_1EDC0(a1 + 72);
while ( (sub_1EDF0(v14, &v13) & 1) != 0 )
{
v12 = sub_1EE20(v14);
sub_1EE90(v11, v12, v12 + 4);
sub_1EE60(a1 + 128, v11);
sub_1EEC0(v14);
}
sub_1D2E0(v10);
v9 = 0;
v8 = 0;
v7[1] = a1 + 24;
v7[0] = sub_1E8B0(a1 + 24);
v6 = sub_1E8E0(a1 + 24);
while ( (sub_1E910(v7, &v6) & 1) != 0 )
{
v5 = *(_BYTE *)sub_1E960(v7);
v4 = *(_QWORD *)sub_1EEF0(a1 + 72, &v5);
for ( i = BYTE4(v4) - 1; i >= 0; --i )
{
v9 |= (((unsigned int)v4 >> i) & 1) << (7 - v8++);
if ( v8 == 8 )
{
sub_1EF20(v10, &v9);
v9 = 0;
v8 = 0;
}
}
sub_1E9A0(v7);
}
if ( v8 )
sub_1EF20(v10, &v9);
sub_1EFC0(a1 + 48, v10);
sub_1F280(v10);
return sub_1F2F0(v21);
}

델타 버퍼로부터 빈도를 계산해 허프만 코드북을 만들고 비트스트림을 생성한다. 출력은 (심볼, 코드길이, 코드) 12바이트 엔트리 묶음과 압축 데이터이다.

4. 상수 키 XOR

unsigned __int64 __fastcall sub_1DB00(__int64 a1, __int64 a2)
{
unsigned __int64 v2; // rcx
unsigned __int64 result; // rax
_BYTE *v4; // rax
char v5; // [rsp+4h] [rbp-2Ch]
unsigned __int64 i; // [rsp+18h] [rbp-18h]
for ( i = 0; ; ++i )
{
v2 = sub_1DC20(a2);
result = i;
if ( i >= v2 )
break;
v5 = *(_BYTE *)(a1 + (i & 0x1F) + 152);
v4 = (_BYTE *)sub_1DE20(a2, i);
*v4 ^= v5;
}
return result;
}
__int64 __fastcall sub_1DC20(_QWORD *a1)
{
return a1[1] - *a1;
}
unsigned __int64 __fastcall sub_1DB90(__int64 a1, __int64 a2)
{
unsigned __int64 v2; // rcx
unsigned __int64 result; // rax
unsigned __int8 *v4; // rax
char v5; // [rsp+7h] [rbp-29h]
unsigned __int64 i; // [rsp+18h] [rbp-18h]
for ( i = 0; ; ++i )
{
v2 = sub_1DC20(a2);
result = i;
if ( i >= v2 )
break;
v4 = (unsigned __int8 *)sub_1DE20(a2, i);
v5 = sub_23880(a1, *v4);
*(_BYTE *)sub_1DE20(a2, i) = v5;
}
return result;
}
__int64 __fastcall sub_23880(__int64 a1, unsigned __int8 a2)
{
return ((int)a2 >> 1) & 1u
| (unsigned __int8)((2 * (((int)a2 >> 6) & 1))
| (4 * (((int)a2 >> 3) & 1))
| (8 * (((int)a2 >> 4) & 1))
| (16 * (((int)a2 >> 5) & 1))
| (32 * (((int)a2 >> 2) & 1))
| ((((int)a2 >> 7) & 1) << 6)
| ((a2 & 1) << 7));
}

32바이트 상수 키랑 XOR하고 sub_1DB90이 고정 비트순열을 적용한다.

상수값은 여기에 있다.

__int64 __fastcall sub_1CDE0(__int64 a1)
{
__int64 result; // rax
*(_QWORD *)a1 = 0;
*(_DWORD *)(a1 + 8) = 0;
*(_DWORD *)(a1 + 12) = 0;
*(_DWORD *)(a1 + 16) = 0;
sub_1D2E0(a1 + 24);
sub_1D2E0(a1 + 48);
sub_1D300(a1 + 72);
sub_1D320(a1 + 128);
result = a1;
*(_QWORD *)(a1 + 152) = 0x22148FE000A8F028LL;
*(_QWORD *)(a1 + 160) = 0xD59FC621AB138A2ELL;
*(_QWORD *)(a1 + 168) = 0xF943272CFF4841EFLL;
*(_QWORD *)(a1 + 176) = 0xA93EE157F3AEC4C9LL;
return result;
}

정리하면 28f0a800e08f14222e8a13ab21c69fd5ef4148ff2c2743f9c9c4aef357e13ea9

마지막으로 헤더(36바이트) + 코드북(엔트리 수 x 6바이트) + 비트스트림으로 기록

flag.out

![[cie-flag.out.png]]

OffsetSizeDesc
0x006HSPACE Magic
0x061버전 (관측값 0x01)
0x071플래그 (관측값 0xA8)
0x084폭 (little endian)
0x0C4높이
0x104압축 데이터 길이 (encoded_len)
0x144원본 RGB 길이 (decoded_len)
0x184허프만 엔트리 수 (code_count, 관측값 165)
0x1C8예약(0)
0x242실제 코드북 엔트리 수 (헤더 값과 동일)
이후6 x N허프만 엔트리: sym(1) + len(1) + code(4)
마지막encoded_len비트스트림 (XOR/비트순열 적용됨)

Exploit

  1. 헤더, 코드북 파싱: 코드북은 (길이, 코드) \rightarrow 심볼로 재구성
  2. 비트스트림 후처리
    1. 복원비트순열 역변환: b7 b0 b5 b2 b3 b4 b1 b6
    2. 32바이트 키와 반복 XOR.
  3. 허프만 복호화: 비트 단위로 읽어 코드북과 일치하는 순간 심볼 출력
  4. 델타 역변환: 인덱스 3부터 buf[i] = (buf[i] + buf[i-3]) & 0xFF.
  5. 폭과 높이에 맞춰 RGB Buffer를 PNG로 저장
import struct
from pathlib import Path
from PIL import Image
KEY = bytes.fromhex("28f0a800e08f14222e8a13ab21c69fd5ef4148ff2c2743f9c9c4aef357e13ea9")
def inv_perm(byte: int) -> int:
return (
((byte >> 7) & 1)
| (((byte >> 0) & 1) << 1)
| (((byte >> 5) & 1) << 2)
| (((byte >> 2) & 1) << 3)
| (((byte >> 3) & 1) << 4)
| (((byte >> 4) & 1) << 5)
| (((byte >> 1) & 1) << 6)
| (((byte >> 6) & 1) << 7)
)
def read_header(data: bytes):
header_fmt = "<6sBBIIIIIII"
header_size = struct.calcsize(header_fmt)
magic, version, flags, width, height, enc_len, total_len, code_count, _, _ = struct.unpack(
header_fmt, data[:header_size]
)
if magic != b"HSPACE":
raise ValueError("Invalid magic value")
return {
"width": width,
"height": height,
"encoded_len": enc_len,
"decoded_len": total_len,
"code_count": code_count,
}, header_size
def parse_codebook(data: bytes, offset: int, count: int):
(entry_count,) = struct.unpack_from("<H", data, offset)
offset += 2
table = {}
for _ in range(entry_count):
symbol = data[offset]
length = data[offset + 1]
code = struct.unpack_from("<I", data, offset + 2)[0]
offset += 6
code &= (1 << length) - 1
table[(length, code)] = symbol
return table, offset
def undo_postprocessing(payload: bytes) -> bytes:
buf = bytearray(inv_perm(b) for b in payload)
key_len = len(KEY)
for i in range(len(buf)):
buf[i] ^= KEY[i % key_len]
return bytes(buf)
def huffman_decode(bitstream: bytes, table: dict, expected_len: int) -> bytearray:
output = bytearray()
acc = 0
bits = 0
max_len = max(length for length, _ in table.keys())
for byte in bitstream:
for shift in range(7, -1, -1):
bit = (byte >> shift) & 1
acc = ((acc << 1) | bit) & ((1 << max_len) - 1)
bits += 1
key = (bits, acc)
symbol = table.get(key)
if symbol is not None:
output.append(symbol)
acc = 0
bits = 0
if len(output) == expected_len:
return output
return output
def reverse_delta(data: bytearray):
for i in range(3, len(data)):
data[i] = (data[i] + data[i - 3]) & 0xFF
def decode_hspace(path: Path) -> Image.Image:
raw = path.read_bytes()
header, offset = read_header(raw)
codebook, offset = parse_codebook(raw, offset, header["code_count"])
payload = raw[offset : offset + header["encoded_len"]]
processed = undo_postprocessing(payload)
decoded = huffman_decode(processed, codebook, header["decoded_len"])
reverse_delta(decoded)
return Image.frombytes("RGB", (header["width"], header["height"]), bytes(decoded))
if __name__ == "__main__":
img = decode_hspace(Path("flag2.out"))
img.save("flag.png")

![[cie-flag.png]]

HSPACE{3c1016249b1cb2308262855b5fe4e5e8432076c7f7ff5ae4df4670e9af86faed}