오늘은 CodeGate 2026 write up을 작성할 것입니다.
greybox
__int64 __fastcall main(int a1, char **a2, char **a3)
{
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
memset(&unk_404140, 0, 0x40uLL);
printf("Input: ");
if ( read(0, &unk_404140, 0x40uLL) != 64 )
{
puts("Input length must be 64bytes...");
_exit(-1);
}
sub_401883();
return 0LL;
}
먼저 prob 파일의 main 함수를 디컴파일한 결과입니다.
main 함수에서는 입력값을 unk_404140에 저장합니다.
그 다음 입력값이 64바이트가 맞는지 체크하고 sub_401883 함수를 호출합니다.
__int64 sub_401883()
{
int i; // [rsp+4h] [rbp-3Ch]
int j; // [rsp+8h] [rbp-38h]
int k; // [rsp+Ch] [rbp-34h]
__int64 v4; // [rsp+10h] [rbp-30h]
_DWORD *v5; // [rsp+18h] [rbp-28h]
__int64 v6[4]; // [rsp+20h] [rbp-20h]
v6[3] = __readfsqword(0x28u);
v4 = *(_QWORD *)(stderr + 216); // stderr의 vtable 포인터 저장
v5 = calloc(1uLL, 0x430uLL); // VM 구조체 0x430 바이트 크기 할당
v5[9] = 256;
sub_401790(v5); // target 파일 바이트 코드 읽어, 포인터 저장
for ( i = 0; i <= 15; ++i ) // 입력값을 VM의 레지스터에 저장
v5[i + 12] = *((_DWORD *)&unk_404140 + i); // 입력값을 4바이트 씩 16번 저장
v6[0] = (__int64)calloc(1uLL, 0xF0uLL) + 16; // 힙에 메모리 할당
v6[1] = (__int64)calloc(1uLL, 0xF0uLL) + 16;
for ( j = 0; j <= 1; ++j ) // 가짜 FILE 구조체 생성
{
*(_QWORD *)(v6[j] + 200) = v5; // v5 구조체를 포인터에 저장
*(_QWORD *)(v6[j] + 104) = v6[(j + 1) % 2]; // v6[0]과 v6[1]을 연결
*(_QWORD *)(v6[j] + 136) = v6[j] - 16;
*(_QWORD *)(v6[j] + 216) = v4 + 504; // 호출할 함수
sub_4016D5(v6[j]);
}
for ( k = 0; k <= 18; ++k ) // dword_4040A0에 호출할 핸들러 offset 존재
*((_QWORD *)&unk_404140 + k + 16) = (char *)sub_4012B6 + dword_4040A0[k]; // 19개의 핸들러 실제 주소를 unk_404140+0x80에 저장 (dispatch table)
*(_QWORD *)(stderr + 104) = v6[0]; // 프로그램 종료될 때 v6[0] 실행 -> v6[1] -> v6[0] 반복
return 0LL;
}
sub_401883 함수는 v5에 VM 구조체를 생성하며 0x430 바이트의 크기를 할당합니다.
그 후, 반복문을 통해 입력값이 담긴 &unk_404140을 v5의[i+12]에 반복적으로 저장하는 것을 통해 레지스터에 입력값을 저장합니다.
v6[0]과 v6[1]에 힙 메모리를 할당하고 반복문을 통해 가짜 FILE 구조체를 만듭니다. 여기서 v5 구조체를 저장하고, v6[(j + 1) % 2]를 통해 v6[0]과 v6[1]을 반복적으로 서로 연결합니다. 그리고 v4+504를 저장하여 glibc가 호출할 함수를 저장합니다.
dword_4040A0 dd 11h, 25h, 59h, 89h, 0C1h, 0F9h, 12Eh, 163h, 197h, 1CFh
dd 207h, 23Bh, 26Fh, 2A3h, 2D7h, 30Bh, 348h, 369h, 39Ch
마지막으로 [unk_404140 + 0x80 + k]에 sub_4012B6(base 주소) + dword_4040A0[k](핸들러 offset)을 저장하여 총 19개의 핸들러를 저장합니다.
__int64 __fastcall sub_401790(__int64 a1)
{
FILE *stream; // [rsp+10h] [rbp-10h]
__int64 size; // [rsp+18h] [rbp-8h]
stream = fopen("./target", "rb"); // target 파일 열기
if ( !stream )
_exit(-1);
fseek(stream, 0LL, 2);
size = ftell(stream); // 파일 크기 구하기
if ( !size )
{
fclose(stream);
_exit(-1);
}
fseek(stream, 0LL, 0);
*(_QWORD *)(a1 + 40) = calloc(1uLL, size); // v5 + 0x28에 target 크기만큼 힙 메모리 할당
if ( size != fread(*(void **)(a1 + 40), 1uLL, size, stream) ) // target 파일 바이트 크기만큼 읽어서 v5 + 0x28에 저장
_exit(-1);
fclose(stream);
return 0LL;
}
sub_401790 함수를 보면 target의 바이트 코드를 힙에 저장하고, 그 포인터를 v5 + 0x28에 저장합니다.
__int64 __fastcall sub_4016D5(_QWORD *a1) // a1[25](= a1 + 200) = v5 = VM 구조체
{
memset(a1, 0, 0x60uLL);
a1[5] = 322376503LL;
a1[20] = a1 - 2;
a1[26] = (char *)&unk_404140
+ 8 * *(unsigned __int8 *)(*(_QWORD *)(a1[25] + 40LL) + *(unsigned int *)(a1[25] + 32LL))
+ 8 * (unsigned __int64)(a1[17] < a1[13]); // vtable = &unk_404140 + (opcode + carry) * 8 <- glibc가 a1[26]을 통해 핸들러 호출
return 0LL;
}
sub_4016D5 함수는 v6[0], v6[1]이 실행될 때 호출할 VM 핸들러를 결정하는 vtable을 세팅합니다.
sub_401883 함수 마지막에 unk_404140 + 0x80 + k에서 19개의 핸들러를 저장하고 여기서 opcode로 꺼내 사용합니다.
그 후 sub_401883 함수가 리턴되고 main 함수에서 return 0을 실행하면 glibc가 exit() 함수를 호출합니다.
이 때 glibc는 FILE 체인을 순회하는데 stderr의 _chain에 v6[0]을 저장해두었기에 stderr를 통해 v6[0]이 실행되고,
v6[0] -> v6[1] -> v6[0]이 반복됩니다.
import struct
data = open("deploy/target", "rb").read()
names = {
0:'NOP', 1:'MOV', 2:'LDI', 3:'LDX', 4:'STX', 5:'LD', 6:'ST',
7:'ADD', 8:'SUB', 9:'MUL', 10:'AND', 11:'OR', 12:'XOR',
13:'SHL', 14:'SHR', 15:'CMP', 16:'JMP', 17:'JNE', 18:'END'
}
def instr_size(j):
if j in (0, 18): return 1
if j == 2: return 6
if j in (16, 17): return 5
return 3
def carry_at(target_pc):
carry = 1
pc = 0
while pc < target_pc:
op = data[pc]
j = op + carry - 3
pc += instr_size(j)
carry ^= 1
return carry
def disasm(start_pc, start_carry):
pc = start_pc
carry = start_carry
while pc < len(data):
op = data[pc]
j = op + carry - 3
if j not in names:
print(f"PC={pc:4d} UNKNOWN (raw=0x{op:02x})")
break
name = names[j]
size = instr_size(j)
if j == 18:
print(f"PC={pc:4d} END")
break
elif j == 0:
print(f"PC={pc:4d} NOP")
elif j == 2:
B = data[pc+1]
imm = struct.unpack_from('<I', data, pc+2)[0]
print(f"PC={pc:4d} LDI r{B}, {imm:#010x}")
elif j in (16, 17):
imm = struct.unpack_from('<i', data, pc+1)[0]
print(f"PC={pc:4d} {name:4s} {imm:+d} -> PC={pc+imm}")
else:
A, B = data[pc+1], data[pc+2]
print(f"PC={pc:4d} {name:4s} r{A}, r{B}")
pc += size
carry ^= 1
print("=== [초기화] PC=0 ===")
disasm(0, carry_at(0))
print("\n=== PC=429 ===")
disasm(429, carry_at(429))
print("\n=== [검증] PC=23 ===")
disasm(23, carry_at(23))
해당 코드로 target 파일을 디스어셈블했습니다.
=== PC=429 ===
PC= 429 LD r0, r0
PC= 432 LD r1, r4
PC= 435 LD r2, r8
PC= 438 LD r3, r12
PC= 441 ADD r0, r1
PC= 444 XOR r3, r0
PC= 447 LDI r6, 0xb1293119
PC= 453 LDI r5, 0xb129311e
PC= 459 XOR r6, r5 // 0x7
PC= 462 MOV r4, r3
PC= 465 SHL r3, r6 // 7비트 시프트
PC= 468 LDI r6, 0xc0b814eb
PC= 474 LDI r5, 0xc0b814f2
PC= 480 XOR r6, r5 // 0x19 = 25
PC= 483 SHR r4, r6 // 25비트 시프트
PC= 486 OR r3, r4
PC= 489 ADD r2, r3
PC= 492 XOR r1, r2
PC= 495 LDI r6, 0x0a71fa8b
PC= 501 LDI r5, 0x0a71fa82
PC= 507 XOR r6, r5 // 0x9
PC= 510 MOV r4, r1
PC= 513 SHL r1, r6 // 9비트 시프트
PC= 516 LDI r6, 0xdf0d02d4
PC= 522 LDI r5, 0xdf0d02c3
PC= 528 XOR r6, r5 // 0x17 = 23
PC= 531 SHR r4, r6 // 23비트 시프트
PC= 534 OR r1, r4
PC= 537 ADD r0, r1
PC= 540 XOR r3, r0
PC= 543 LDI r6, 0xd4334860
PC= 549 LDI r5, 0xd433486d
PC= 555 XOR r6, r5 // 0xd = 13
PC= 558 MOV r4, r3
PC= 561 SHL r3, r6 // 13비트 시프트
PC= 564 LDI r6, 0xe522dd57
PC= 570 LDI r5, 0xe522dd44
PC= 576 XOR r6, r5 // 0x13 = 19
PC= 579 SHR r4, r6 // 19비트 시프트
PC= 582 OR r3, r4
PC= 585 ADD r2, r3
PC= 588 XOR r1, r2
PC= 591 LDI r6, 0xcaf211d2
PC= 597 LDI r5, 0xcaf211c0
PC= 603 XOR r6, r5 // 0x12 = 18
PC= 606 MOV r4, r1
PC= 609 SHL r1, r6 // 18비트 시프트
.
.
.
디스어셈블 결과 중 일부를 보면 7, 9, 13, 18 순서로 왼쪽 시프트를 반복하는데, 이를 통해 Salsa20 통해 입력값을 암호화한다는 것을 알 수 있습니다. shr을 하는 부분은 VM에 비트 회전 명령어가 없어 SHL 7 + SHR 25 + OR 3개로 비트 회전을 구현합니다.
=== PC=23 (carry=0) ===
PC= 23 LD r0, r0
PC= 26 LDI r1, 0x721f4fa3
PC= 32 LDI r4, 0xb36c7aa9
PC= 38 XOR r1, r4 // correct[0] = 0x721f4fa3 ^ 0xb36c7aa9
PC= 41 XOR r0, r1 // Salsa20_input[0] ^ correct[0]
PC= 44 OR r7, r0 // 결과 r7에 누적
PC= 47 LD r0, r1
PC= 50 LDI r1, 0xc7d0da5e
PC= 56 LDI r4, 0x73e48aab
PC= 62 XOR r1, r4 // correct[1] = 0xc7d0da5e ^ 0x73e48aab
PC= 65 XOR r0, r1 // Salsa20_input[1] ^ correct[1]
PC= 68 OR r7, r0 // 결과 r7에 누적
PC= 71 LD r0, r2
.
.
.
마지막 검증 부분은 다음과 같습니다.
해당 부분에서 반복적으로 xor을 진행하며 정답 배열을 만들고 Salsa20으로 암호화된 입력값을 비교합니다.
마지막 END 핸들러로 sub_401652 함수로 이동합니다.
__int64 __fastcall sub_401652(__int64 a1)
{
if ( *(_DWORD *)(*(_QWORD *)(a1 + 200) + 28LL) ) // a1 + 200(0xc8) = v5 VM 구조체
{ // v5 + 28(0x1c) = r7
puts("Wrong!\n"); // 즉, r7이 0이면 Success, 아니면 Wrong
}
else
{
puts("Sucess!");
printf("Flag is codegate2026{%.*s}\n\n", 64, byte_404140);
}
*(_QWORD *)(a1 + 104) = stdout;
return 0LL;
}
sub_401652 함수는 r7가 0인지 아닌지 판단하여 0이면 Success, 0이 아니면 Wrong을 출력합니다.
r7이 0이라는 것은 Salsa20_input[n] ^ correct[n]의 결과가 0이므로 같은 값을 의미합니다.
r7이 0일 때 Success를 출력하며 Flag를 출력하는데 byte_404140이며, 이는 입력값이 Flag임을 의미합니다.
import struct
EXPECTED_XOR_PAIRS = [
(0x721f4fa3, 0xb36c7aa9),
(0xc7d0da5e, 0x73e48aab),
(0x123da2a9, 0xe519b93d),
(0xce0725d7, 0x2cd8786c),
(0x6049942b, 0xaf91e8e1),
(0x673f94c0, 0x470a5443),
(0xeeb3de53, 0x3cdf0408),
(0x4a3a3b30, 0x272544be),
(0xe4de6612, 0xab106c98),
(0xee94b713, 0x01326b92),
(0x151c98c3, 0xa45be7c6),
(0xf078cf76, 0xed7f4048),
(0x7ccaf263, 0x9c64f3b8),
(0xbf750bc1, 0x36c3044b),
(0x2601456e, 0x2db5a696),
(0x4f54acea, 0xcce4d035),
]
MASK = 0xffffffff
NUM_ROUNDS = 8
def rotl(v, n):
return ((v << n) | (v >> (32 - n))) & MASK
def rotr(v, n):
return ((v >> n) | (v << (32 - n))) & MASK
def qr(a, b, c, d):
a = (a + b) & MASK; d = rotl(d ^ a, 7)
c = (c + d) & MASK; b = rotl(b ^ c, 9)
a = (a + b) & MASK; d = rotl(d ^ a, 13)
c = (c + d) & MASK; b = rotl(b ^ c, 18)
return a, b, c, d
def qr_inv(a, b, c, d):
b = rotr(b, 18); b ^= c; c = (c - d) & MASK
d = rotr(d, 13); d ^= a; a = (a - b) & MASK
b = rotr(b, 9); b ^= c; c = (c - d) & MASK
d = rotr(d, 7); d ^= a; a = (a - b) & MASK
return a, b, c, d
def double_round(s):
s = list(s)
s[0], s[4], s[8], s[12] = qr(s[0], s[4], s[8], s[12])
s[1], s[5], s[9], s[13] = qr(s[1], s[5], s[9], s[13])
s[2], s[6], s[10], s[14] = qr(s[2], s[6], s[10], s[14])
s[3], s[7], s[11], s[15] = qr(s[3], s[7], s[11], s[15])
s[0], s[5], s[10], s[15] = qr(s[0], s[5], s[10], s[15])
s[1], s[6], s[11], s[12] = qr(s[1], s[6], s[11], s[12])
s[2], s[7], s[8], s[13] = qr(s[2], s[7], s[8], s[13])
s[3], s[4], s[9], s[14] = qr(s[3], s[4], s[9], s[14])
return s
def double_round_inv(s):
s = list(s)
s[3], s[4], s[9], s[14] = qr_inv(s[3], s[4], s[9], s[14])
s[2], s[7], s[8], s[13] = qr_inv(s[2], s[7], s[8], s[13])
s[1], s[6], s[11], s[12] = qr_inv(s[1], s[6], s[11], s[12])
s[0], s[5], s[10], s[15] = qr_inv(s[0], s[5], s[10], s[15])
s[3], s[7], s[11], s[15] = qr_inv(s[3], s[7], s[11], s[15])
s[2], s[6], s[10], s[14] = qr_inv(s[2], s[6], s[10], s[14])
s[1], s[5], s[9], s[13] = qr_inv(s[1], s[5], s[9], s[13])
s[0], s[4], s[8], s[12] = qr_inv(s[0], s[4], s[8], s[12])
return s
def solve():
E = [(a ^ b) & MASK for a, b in EXPECTED_XOR_PAIRS]
state = list(E)
for _ in range(NUM_ROUNDS):
state = double_round_inv(state)
check = list(state)
for _ in range(NUM_ROUNDS):
check = double_round(check)
assert check == E
return b''.join(struct.pack('<I', v) for v in state)
if __name__ == '__main__':
flag_bytes = solve()
print(f"codegate2026{{{flag_bytes.decode()}}}")
다음과 같이 익스를 작성했습니다.
해당 익스에서는 correct를 만드는 값들을 모두 가져와 xor을 통해 correct를 만들고, 이를 역연산하여 정답 input값을 구합니다.

Ghost
#!/usr/bin/env python3
import secrets
from utils import dm_compress, hex_to_words, round_core
from secret import SBOXES, BANNER, FLAG
def main():
iv = secrets.randbits(64)
chances = 2**7
print(BANNER)
print(f"IV = {iv:016x}")
print(f"Chances = {chances}/{2**7}")
while True:
print(
"\n"
"[1] query\n"
"[2] submit\n"
"[3] quit"
)
choice = input("> ")
if choice == "1":
if chances <= 0:
print("Nope!\n")
continue
right_s = input("right > ")
key_s = input("subkey > ")
try:
right = int(right_s, 16)
subkey = int(key_s, 16)
except:
print("Bad input\n")
continue
chances -= 1
y = round_core(right, subkey, SBOXES)
print(f"core = {y:08x}")
elif choice == "2":
m1s = input("m1 > ")
m2s = input("m2 > ")
try:
w1 = hex_to_words(m1s)
w2 = hex_to_words(m2s)
except Exception as e:
continue
if w1 == w2:
print("Blocks must differ\n")
continue
h1 = dm_compress(iv, w1, SBOXES)
h2 = dm_compress(iv, w2, SBOXES)
if h1 == h2:
print("Good!")
print(f"flag = {FLAG()}\n")
else:
print("Nope!")
return
elif choice == "3":
print("Bye!\n")
return
else:
print("Only 1,2,3 are allowed\n")
if __name__ == "__main__":
main()
server.py는 다음과 같습니다.
해당 코드는 시작 시 64비트 랜덤 값 iv를 생성해 16진수로 출력하고, 총 2^7(128)번의 query 기회를 제공합니다.
1번 메뉴(query)를 선택하면 right_s와 key_s를 입력 받아 정수로 변환한 뒤, SBOXES와 함께 round_core 연산을 하여 결과를 출력합니다. 2번 메뉴(submit)를 선택하면 256비트 크기의 m1s와 m2s를 받아 두 입력이 다른지 확인한뒤, hex_to_words 8개의 32비트 워드로 변환하고 dm_compress를 계산합니다.
즉, m1s와 m2s가 서로 다른 값이어야 하며, dm_compress 결과가 같은 경우 flag를 출력합니다.
MASK32 = 0xFFFFFFFF
MASK64 = 0xFFFFFFFFFFFFFFFF
def dm_compress(iv, key_words, sboxes):
return encrypt_block(iv, key_words, sboxes) ^ iv
def encrypt_block(block, key_words, sboxes):
state = split_block(block)
state = encrypt_rounds_from_state(state, full_schedule(key_words), sboxes)
return join_block(*state)
def split_block(block):
return ((block >> 32) & MASK32, block & MASK32)
def encrypt_rounds_from_state(state, round_keys, sboxes):
cur = state
for k in round_keys:
cur = apply_round(cur, k, sboxes)
return cur
def apply_round(state, subkey, sboxes):
left, right = state
return (right & MASK32, (left ^ round_core(right, subkey, sboxes)) & MASK32)
def round_core(right, subkey, sboxes):
return rotl32(sbox_layer((right + subkey) & MASK32, sboxes), 11)
def rotl32(x, r):
x &= MASK32
return ((x << r) & MASK32) | (x >> (32 - r))
def sbox_layer(x, sboxes):
y = 0
for i in range(8):
nib = (x >> (4 * i)) & 0xF
y |= (sboxes[i][nib] & 0xF) << (4 * i)
return y & MASK32
def full_schedule(key_words):
if len(key_words) != 8:
raise ValueError("expected 8 key words")
return list(key_words) * 3 + list(reversed(key_words))
def hex_to_words(hex_string):
s = hex_string.strip().lower()
if s.startswith("0x"):
s = s[2:]
if len(s) != 64 or any(c not in "0123456789abcdef" for c in s):
raise ValueError("message block must be 64 hex chars")
return [int(s[i : i + 8], 16) for i in range(0, 64, 8)]
def join_block(left, right):
return ((left & MASK32) << 32) | (right & MASK32)
utils.py를 보면 압축 함수를 확인할 수 있습니다.
dm_compress 함수는 iv를 key_words로 암호화한 뒤, iv로 XOR하는 방식입니다.
encrypt_block은 64비트 블록을 상위 32비트, 하위 32비트로 나누고 encrypt_rounds_from_state 함수로 반복적으로 값을 수정하며, 실제 연산은 apply_round 함수에서 진행합니다.
def apply_round(state, subkey, sboxes):
left, right = state
return (right & MASK32, (left ^ round_core(right, subkey, sboxes)) & MASK32)
apply_round 함수를 보면 right 값을 왼쪽으로 넣고, left 값은 round_core(right, subkey, sboxes)와 XOR하여 오른쪽으로 넣습니다.
즉, 오른쪽 값으로 계산한 결과를 왼쪽 값과 XOR하여 오른쪽 값을 만드는 구조입니다.
def round_core(right, subkey, sboxes):
return rotl32(sbox_layer((right + subkey) & MASK32, sboxes), 11)
def sbox_layer(x, sboxes):
y = 0
for i in range(8):
nib = (x >> (4 * i)) & 0xF
y |= (sboxes[i][nib] & 0xF) << (4 * i)
return y & MASK32
def rotl32(x, r):
x &= MASK32
return ((x << r) & MASK32) | (x >> (32 - r))
round_core 함수는 sbox_layer(right + subkey, sboxes)를 계산하고 11비트 왼쪽 rotate를 수행합니다.
sbox_layer 함수는 32비트 값을 4비트 씩 총 8개에 대하여 S-box를 적용합니다.
def full_schedule(key_words):
if len(key_words) != 8:
raise ValueError("expected 8 key words")
return list(key_words) * 3 + list(reversed(key_words))
full_schedule 함수에서 사용자가 입력한 256비트 입력값은 8개의 32비트 워드로 나뉘고, 이 값들이 32개의 라운드 key로 확장되어 암호화 과정에 사용됩니다. 결국 입력값은 그냥 입력값이 아닌, 내부 암호화에서 key 역할을 하는 것을 알 수 있습니다.
이제 SBOXES를 구해야 합니다.
def round_core(right, subkey, sboxes):
return rotl32(sbox_layer((right + subkey) & MASK32, sboxes), 11)
SBOXES를 구하기 위해서는 1번 메뉴에서 호출하는 round_core 함수를 사용하면 됩니다.
사용자가 right 입력 값을 0으로 주면 round_core(0, subkey, sboxes)가 되며 결과는 rotl32(sbox_layer(subkey, sboxes), 11)이 됩니다. 회전은 단순 비트 재배치이기에, 출력값을 다시 11비트 오른쪽 rotate를 수행하면 sbox_layer(subkey, sboxes) 결과를 구할 수 있습니다.
def sbox_layer(x, sboxes):
y = 0
for i in range(8):
nib = (x >> (4 * i)) & 0xF
y |= (sboxes[i][nib] & 0xF) << (4 * i)
return y & MASK32
sbox_layer 함수는 32비트 값을 4비트 씩 8개로 나눠 각 위치마다 하나의 S-box를 적용합니다.
가능한 입력은 0~15로 총 16개이며, 총 8개의 S-box가 있으므로 총 128개를 알아내야 하지만 입력 횟수가 128번입니다.
만약 right를 0으로 두고 subkey도 0으로 둔다면, x는 0이기에 모든 S-box에 0이 들어간 결과를 구할 수 있습니다.
그래서 7개의 입력 횟수를 아껴 총 121번의 입력으로 S-box를 구할 수 있습니다.
이후 subkey를 0x1, 0x2....0xf로 입력하여 첫 번째 S-box를 구할 수 있고 0x10, 0x20...0xf0으로 두 번째 S-box를
구할 수 있습니다. 이를 반복해 8개의 S-box를 구하면 됩니다.
문제는 세션마다 SBOXES와 iv가 달라지기에, SBOXES를 구하고, SBOXES와 iv로 메시지 충돌을 계산한 뒤, submit까지 해야 합니다.
#!/usr/bin/env python3
import re
import subprocess
from pwn import remote, context
import z3
HOST = "43.200.71.14"
PORT = 13479
context.log_level = "error"
MASK32 = 0xffffffff
def rotl32(x, r):
x &= MASK32
return ((x << r) & MASK32) | (x >> (32 - r))
def rotr32(x, r):
x &= MASK32
return ((x >> r) | ((x << (32 - r)) & MASK32)) & MASK32
def swap64(x):
return ((x & MASK32) << 32) | ((x >> 32) & MASK32)
def solve_pow(chal: str) -> str:
out = subprocess.check_output(
["python3", "kctf_pow.py", "solve", chal],
text=True
)
lines = [x.strip() for x in out.splitlines() if x.strip()]
sol = lines[-1]
if not sol.startswith("s."):
raise RuntimeError(f"bad pow output: {out!r}")
return sol
def build_queries():
vals = [0]
for i in range(8):
for v in range(1, 16):
vals.append(v << (4 * i))
return vals
def recover_sboxes(core_map):
base = rotr32(core_map[0], 11)
sboxes = [[0] * 16 for _ in range(8)]
for i in range(8):
sboxes[i][0] = (base >> (4 * i)) & 0xF
for i in range(8):
for v in range(1, 16):
x = v << (4 * i)
y = rotr32(core_map[x], 11)
sboxes[i][v] = (y >> (4 * i)) & 0xF
return sboxes
def z3_sbox_layer(x, sboxes):
y = z3.BitVecVal(0, 32)
for i in range(8):
nib = z3.Extract(4 * i + 3, 4 * i, x)
expr = z3.BitVecVal(sboxes[i][0], 4)
for v in range(1, 16):
expr = z3.If(nib == z3.BitVecVal(v, 4), z3.BitVecVal(sboxes[i][v], 4), expr)
y = y | (z3.ZeroExt(28, expr) << (4 * i))
return y
def z3_round_core(right, subkey, sboxes):
return z3.RotateLeft(z3_sbox_layer(right + subkey, sboxes), 11)
def z3_U(block: int, keys, sboxes):
L = z3.BitVecVal((block >> 32) & MASK32, 32)
R = z3.BitVecVal(block & MASK32, 32)
for k in keys:
newL = R
newR = L ^ z3_round_core(R, k, sboxes)
L, R = newL, newR
return z3.Concat(L, R)
def solve_one(iv: int, sboxes, block_first_word=None, seed=1, timeout_ms=30000):
keys = [z3.BitVec(f"k{i}_{seed}", 32) for i in range(8)]
s = z3.Solver()
s.set(timeout=timeout_ms)
s.set(random_seed=seed)
siv = swap64(iv)
s.add(z3_U(iv, keys, sboxes) == z3.BitVecVal(siv, 64))
s.add(z3_U(siv, keys, sboxes) == z3.BitVecVal(iv, 64))
if block_first_word is not None:
s.add(keys[0] != z3.BitVecVal(block_first_word, 32))
if s.check() != z3.sat:
return None
m = s.model()
return [m[k].as_long() for k in keys]
def words_to_hex(words):
return "".join(f"{w:08x}" for w in words)
def main():
vals = build_queries()
io = remote(HOST, PORT, timeout=15)
banner = io.recvuntil(b"Solution? ").decode(errors="ignore")
m = re.search(r"solve\s+(s\.[A-Za-z0-9+/=._-]+)", banner)
if not m:
raise RuntimeError("pow challenge not found")
chal = m.group(1)
sol = solve_pow(chal)
io.sendline(sol.encode())
text = io.recvuntil(b"> ").decode(errors="ignore")
print(text)
m_iv = re.search(r"IV\s*=\s*([0-9a-fA-F]{16})", text)
if not m_iv:
raise RuntimeError("IV not found")
iv = int(m_iv.group(1), 16)
print(f"[+] IV = {iv:016x}")
payload = b""
for x in vals:
payload += b"1\n"
payload += b"0\n"
payload += f"{x:x}\n".encode()
io.send(payload)
cores = []
buf = b""
while len(cores) < 121:
chunk = io.recv(timeout=10)
if not chunk:
raise RuntimeError("connection closed while reading cores")
buf += chunk
found = re.findall(rb"core\s*=\s*([0-9a-fA-F]{8})", buf)
if found:
cores = [int(x, 16) for x in found]
if len(cores) < 121:
raise RuntimeError(f"not enough cores: {len(cores)}")
cores = cores[:121]
core_map = {x: c for x, c in zip(vals, cores)}
sboxes = recover_sboxes(core_map)
print("[+] SBOXES recovered")
for i, box in enumerate(sboxes):
print(f"SBOX[{i}] = {box}")
msg1_words = solve_one(iv, sboxes, block_first_word=None, seed=1)
if msg1_words is None:
raise RuntimeError("failed to find first solution")
msg2_words = None
for seed in range(2, 50):
cand = solve_one(iv, sboxes, block_first_word=msg1_words[0], seed=seed)
if cand is not None and cand != msg1_words:
msg2_words = cand
break
if msg2_words is None:
raise RuntimeError("failed to find second distinct solution")
m1 = words_to_hex(msg1_words)
m2 = words_to_hex(msg2_words)
print(f"[+] m1 = {m1}")
print(f"[+] m2 = {m2}")
if b"> " not in buf:
io.recvuntil(b"> ")
io.sendline(b"2")
io.recvuntil(b"m1 > ")
io.sendline(m1.encode())
io.recvuntil(b"m2 > ")
io.sendline(m2.encode())
print(io.recvall(timeout=5).decode(errors="ignore"))
if __name__ == "__main__":
main()
다음과 같이 익스를 작성했습니다.
해당 익스는 먼저 서버가 출력한 iv를 읽고, right=0으로 고정한 상태에서 subkey 값을 증가시키며 query 메뉴에 입력합니다. 이 과정을 통해 core 값을 수집하고, 수집한 core 값들을 각각 11비트 오른쪽 rotate를 적용하여 SBOXES를 구합니다.
SBOXES를 구한 뒤, 해당 세션의 iv와 SBOXES를 사용하여 서버와 같은 dm_compress를 구현할 수 있습니다.
이후 z3를 이용해 조건을 만족하는 8개의 32비트 워드를 계산합니다. z3는 단순히 임의의 값을 구하는 것이 아닌, 현재 세션의 iv에 대해 동일한 압축 결과가 나오는 두 개의 서로 다른 입력값을 구해줍니다.
z3를 통해 구한 2개의 256비트 입력값을 submit 메뉴에 입력하여 flag를 획득할 수 있습니다.

'Knights Frontier_1st > CTF | 워게임' 카테고리의 다른 글
| [ Knights Frontier ] ROP Emporium 올클리어 (0) | 2026.04.02 |
|---|---|
| [ Knights Frontier ] Space Alone 올클리어 (2) | 2026.03.29 |
| [ Knights Frontier ] BombLab 올클리어 & 바이너리 패치 수행 (0) | 2026.03.27 |
| [ RED RACCOON ] TI 실전 추적 챌린지 (0) | 2026.01.23 |
| [ Layer7 ] Layer7 CTF write-up (0) | 2025.11.20 |