2024년 11월 24일 개최된 Whitehat Conference 2024
국방트랙에 싸축 C
팀으로 참여, 간발의 차로 4위로 마무리하며 수상에는 아쉽게 실패하였다. 동일 점수면 공동 3위로 처리해줘야 한다고 생각해요.
여담인데 싸축은 학부시절 우리 과 축구 동아리 이름이다. 안그래도 작은 과 인원 중 약 10%에 달하는 11명+a를 차출해야 하다보니, 싸축은 창단 이래 교내 공식 대회에서 단 1승도 거둔 적이 없다. 그런 와중에 다른 동아리들은 오히려 인원이 넘쳐, 총 2군을 편성하여 출전하곤 했다. 이 때 보통 팀 이름에 A가 붙으면 1군, B가 붙으면 2군이다. 아축 A, 아축 B 뭐 이런 느낌으로…
우린 매년 아무개 B에게 패배한 후 우린 싸축 C라고, 싸축 A가 나왔다면 너흰 죽도 못 쑬 것이라고 소곤소곤 자기 위안하곤 했다. 이번에도 싸축 C가 아닌 B였다면 AESpa쯤은
아무튼…
Problem | Level | Type | Remarks |
---|---|---|---|
malware | - |
Reversing
|
RC4 / |
prepare_attack | - |
Reversing
|
Golang / Salsa20 |
Level(1-10) : Score / Highest Score
malware
무한루프가 동작하는 malware
가 존재하는데 이를 멈출 수 있는 key
를 찾으면 되는 문제이다.
기본 동작
sub_1B74()
의 return
값이 True
가 되도록 하면 문제 조건을 성립시킬 수 있다.
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
pthread_t newthread[2]; // [rsp+0h] [rbp-10h] BYREF
newthread[1] = __readfsqword(0x28u);
dword_5010 = sub_158D(a1, a2, a3);
if ( dword_5010 == -1 )
return 1LL;
pthread_create(newthread, 0LL, start_routine, 0LL);
while ( !dword_5018 && !(unsigned int)sub_1B74() )
{
sub_1D3A();
sleep(0xAu);
}
dword_5018 = 1;
pthread_join(newthread[0], 0LL);
return 0LL;
}
sub_1B74()
v3
, 사용자 입력값 /tmp/stop
을 통해 연산한 값이 s
와 동일하도록 하는 입력값 /tmp/stop
을 찾으면 된다.
_BOOL8 sub_1B74()
{
int i; // [rsp+4h] [rbp-84Ch]
FILE *stream; // [rsp+8h] [rbp-848h]
char v3[48]; // [rsp+10h] [rbp-840h] BYREF
char s[1024]; // [rsp+40h] [rbp-810h] BYREF
char s2[1032]; // [rsp+440h] [rbp-410h] BYREF
unsigned __int64 v6; // [rsp+848h] [rbp-8h]
v6 = __readfsqword(0x28u);
qmemcpy(s2, &unk_3088, 0x400uLL);
strcpy(v3, "1a5c4c610811f8b45fb9bcb452123621");
srand(0);
stream = fopen("/tmp/stop", "r");
if ( !stream )
return 0LL;
memset(s, 0, sizeof(s));
fgets(s, 1024, stream);
fclose(stream);
for ( i = 0; i <= 1023; ++i )
s[i] ^= rand();
sub_18C5(v3, s, 1024LL);
return memcmp(s, s2, 0x400uLL) == 0;
}
위 연산 중 마지막에 수행하는 sub_18C5
의 경우 RC4
알고리즘임을 알 수 있다.
__int64 __fastcall sub_18C5(const char *a1, __int64 a2, int a3)
{
...
v17 = __readfsqword(0x28u);
v11 = 0;
for ( i = 0; i <= 255; ++i ) // S-Box 초기화
v16[i] = i;
for ( j = 0; j <= 255; ++j ) // Key-Scheduling Algorithm (KSA)
{
v3 = v16[j] + v11;
v4 = strlen(a1);
v5 = v3 + a1[j % v4];
LODWORD(v4) = (unsigned int)((v3 + a1[j % v4]) >> 31) >> 24;
v11 = (unsigned __int8)(v4 + v5) - (_DWORD)v4;
v15 = v16[j];
v16[j] = v16[v11];
v16[v11] = v15;
}
v12 = 0;
v10 = 0;
for ( k = 0; k < a3; ++k ) //Pseudo-Random Generation Algorithm (PRGA)
{
v10 = (v10 + 1) % 256;
v12 = (v12 + v16[v10]) % 256;
v14 = v16[v10];
v16[v10] = v16[v12];
v16[v12] = v14;
*(_BYTE *)(k + a2) ^= v16[(v16[v12] + v16[v10]) % 256];
}
return 1LL;
}
PoC
아래 PoC를 통해 sub_18C5(RC4)
의 역연산 결과를 알 수 있다. 즉, s[i] ^= rand();
의 결과 result.dump
를 알 수 있다.
문제에서 srand(0);
에 의하여 동일한 시퀀스의 난수가 생성되므로, 문제 파일을 재실행하여 아래 result.dump
를 /tmp/stop
으로 사용하고, s[i] ^= rand();
의 결과 s
를 보면 구하고자 했던 입력값을 구할 수 있다.
혹은 rand()
값을 직접 생성하여 result.dump
와 xor
연산을 직접수행하여도 된다.
def reverse_keygen(key, modified_s, int_1024):
# 초기화 단계 (256개 S-Box 초기화)
key_len = len(key)
v16 = list(range(256))
v11 = 0
# Key-Scheduling Algorithm (KSA)
for j in range(256):
v11 = (v11 + v16[j] + ord(key[j % key_len])) % 256
v16[j], v16[v11] = v16[v11], v16[j] # Swap
# 복구를 위한 Pseudo-Random Generation Algorithm (PRGA)
v12 = 0
v10 = 0
original_s = bytearray(modified_s) # 연산 이후 s를 복제
for k in range(int_1024):
v10 = (v10 + 1) % 256
v12 = (v12 + v16[v10]) % 256
v16[v10], v16[v12] = v16[v12], v16[v10]
key_stream = v16[(v16[v12] + v16[v10]) % 256]
original_s[k] ^= key_stream
return original_s
if __name__=='__main__':
key = "1a5c4c610811f8b45fb9bcb452123621"
int_1024 = 1024
# 암호화된 데이터 (연산 이후의 s)
with open("s2.dump","rb") as f:
modified_s = f.read(0x400)
# 연산 이전의 s 복구
original_s = reverse_keygen(key, modified_s, len(modified_s))
#print(original_s)
with open ("result.dump","wb") as f:
f.write(original_s)
prepare_attack
실행 시 임의 포트를 함께 파라미터로 입력 받아, 해당 포트로 listen
함과 동시에 무한 루프를 실행한다. 해당 포트로 무한 루프를 멈출 수 있는 적절한 명령(입력값)을 전달하면 flag
를 얻을 수 있다. GoLang
으로 작성되어서 분석하기에 다소 복잡한 면이 있다.
문제 분석
이 문제의 실질적 main
함수는 아래의 main_handleConn()
이다. 최초 입력 시 입력값의 길이가 32
가 아니면 Invalid key length
를 출력하는데, 이 string
을 기반으로 search
해보면 쉽게 메인 로직을 찾을 수 있다.
기본적인 동작 순서는 이렇다.
main_NewEncSocket
main__ptr_EncSocket_Read
runtime_cmpstring
/main_get_flag
/main__ptr_EncSocket_Write
// main.handleConn
// local variable allocation has failed, the output may be wrong!
__int64 __golang main_handleConn(
...
result_1 = (_ptr_main_EncSocket)main_NewEncSocket(a1, a2, (int)v52, a4, a5, a6, a7, a8, a9);
v10 = (main_EncSocket *)runtime_duffzero(v44);
v61.ptr = (uint8 *)v44;
v61.len = 1024LL;
v61.cap = 1024LL;
result_2 = main__ptr_EncSocket_Read(v10, v61);
...
v48 = runtime_slicebytetostring(0LL, (unsigned __int8 *)v44, result_2.0, 1024LL, a5, v11, v12, v13, v14);
v46 = v44;
v17 = (_slice_uint8 *)&unk_4;
if ( !runtime_cmpstring(v48, (__int64)v44, (__int64)"exit", 4LL) )
{
flag = main_get_flag(0LL, (__int64)v44, v18, 4LL, a5, v19, v20, v21, v22);
v24 = &unk_6428A0;
if ( flag )
v24 = (void *)flag;
v17 = v44;
v25 = v24;
v26 = v44;
v59 = main__ptr_EncSocket_Write(result_1, *(_slice_uint8 *)(&v17 - 2));
...
os_Exit(0, 0, (int)v59.1.data, (int)v44, a5, v27, v28, v29, v30, (int)v40);
}
...
}
main_NewEncSocket
사용자로부터 32 byte
입력을 받은 후, libcRand
를 이용하여 key
와 nonce
를 생성하고, 이를 이용하여 사용자 입력을 salsa20
으로 암호화하고 그 결과를 사용자에게 회신한다.
// main.NewEncSocket
char *__golang main_NewEncSocket(__int64 a1, main_libcRand *a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9)
{
...
user_input = runtime_makeslice((unsigned __int64 *)&RTYPE_uint8, 32LL, 32LL, a4, a5, a6, a7, a8, a9);
...
if ( len_input != 32 )
{
*(_QWORD *)&v47 = &RTYPE_string;
*((_QWORD *)&v47 + 1) = &Invalid_key_length_str;
sub_4B4AE0((int)&v47, 1, 1, 32, (int)v10, v13, v14, v15, v16);
}
v44 = ((__int64 (__golang *)(RTYPE **))(*off_61C408)[3])(off_61C408[1]);
p_main_libcRand = (main_libcRand *)runtime_newobject(&RTYPE_main_libcRand);
p_main_libcRand->seed = (int)(v44 >> 33) % 256;
for ( i = 0LL; i < 32; ++i )
{
v23 = 1103515245 * p_main_libcRand->seed + 12345;
v10 = (__int64 (__golang *)(main_libcRand *, _OWORD *, __int64, __int64))(i + 1);
p_main_libcRand->seed = v23;
*((_BYTE *)&key + i) = v23;
}
for ( j = 0LL; j < 8; ++j )
{
v25 = 1103515245 * p_main_libcRand->seed + 12345;
v10 = (__int64 (__golang *)(main_libcRand *, _OWORD *, __int64, __int64))(j + 1);
p_main_libcRand->seed = v25;
*((_BYTE *)&nonce + j) = v25;
}
v45 = p_main_libcRand;
encrypted_input_maybe = main_salsa20Encrypt(
(__int64)user_input,
32LL,
32LL,
32LL,
(__int64)v10,
v18,
v19,
v20,
v21,
key,
v42,
nonce);
send_1 = *(void (__golang **)(main_libcRand *, __int64, __int64, __int64))(a1 + 80);
v29 = v28;
v30 = encrypted_input_maybe;
send_1(a2, encrypted_input_maybe, 32LL, v28); // send1
...
*(_QWORD *)result = v37;
*((_QWORD *)result + 1) = a1;
*((_QWORD *)result + 2) = v38;
*(_OWORD *)(result + 24) = key;
*(_OWORD *)(result + 40) = v42;
return result;
}
main__ptr_EncSocket_Read
main_NewEncSocket
과 비슷하게 사용자 입력을 salsa20
으로 암호화한다. 여기서 주목할 부분은, key
는 main_NewEncSocket
에서 사용한 것을 재활용하고, nonce
의 경우 이전에 사용하던 LCG(libCRand)
를 이어서 활용하여 랜덤 생성한다는 점이다.
// main.(*EncSocket).Read
// local variable allocation has failed, the output may be wrong!
retval_506540 __golang main__ptr_EncSocket_Read(_ptr_main_EncSocket a1, _slice_uint8 a2)
{
...
len = a2.len;
ptr = a2.ptr;
v24 = (void *)runtime_makeslice((unsigned int)&RTYPE_uint8, a2.len, a2.len, a2.cap, v2, v3, v4, v5, v6);
v7 = v24;
rand_func = (_ptr_main_libcRand)len;
v9 = (*((__int64 (__golang **)(void *, void *, __int64, __int64))a1->conn.tab + 5))(a1->conn.data, v24, len, len);
if ( v24 )
{
v16 = 0LL;
}
else
{
nonce2 = 0LL;
for ( i = 0LL; i < 8; ++i )
{
rand_func = a1->rand_func;
v11 = 1103515245 * a1->rand_func->seed + 12345;
v12 = i + 1;
a1->rand_func->seed = v11;
*((_BYTE *)&nonce2 + i) = v11;
}
if ( v9 > len )
runtime_panicSliceAcap(v9, 0LL, v9);
v23 = v9;
key = *(_OWORD *)a1->key;
v22 = *(_OWORD *)&a1->key[16];
v17 = v9;
v18 = main_salsa20Encrypt((__int64)v24, v9, len, (__int64)rand_func, nonce2, v11, v12, v13, v14, key, v22, nonce2);
...
}
result.1.tab = v7;
result.1.data = v10;
result.0 = v16;
return result;
}
runtime_cmpstring / main_get_flag / main__ptr_EncSocket_Write
앞서 두번째 사용자 입력값을 암호화한 값이 만약에 exit
라면 flag
를 읽어온다. main__ptr_EncSocket_Write
함수를 통해 flag
값을 salsa20
암호화하여 사용자에게 전송하고, 무한 루프가 종료된다. 여기에서도 key
는 그대로 재사용하고, nonce
는 이전에 사용하던 LCG(libCRand)
를 이어서 활용하여 랜덤 생성한다.
// main.(*EncSocket).Write
// local variable allocation has failed, the output may be wrong!
retval_506540 __golang main__ptr_EncSocket_Write(_ptr_main_EncSocket a1, _slice_uint8 a2)
{
...
for ( i = 0LL; i < 8; ++i )
{
rand_func = a1->rand_func;
v3 = 1103515245 * a1->rand_func->seed + 12345;
v4 = i + 1;
a1->rand_func->seed = v3;
*((_BYTE *)&v17 + i) = v3;
}
key2 = *(_OWORD *)a1->key;
v19 = *(_OWORD *)&a1->key[16];
ptr = a2.ptr;
len = a2.len;
v10 = main_salsa20Encrypt((__int64)ptr, a2.len, a2.cap, a2.cap, (__int64)rand_func, v3, v4, v5, v6, key2, v19, v17);
v12 = v11;
v13 = len;
v14 = (void *)v10;
v15 = (*((__int64 (__golang **)(void *, __int64, size_t, __int64))a1->conn.tab + 10))(a1->conn.data, v10, v13, v12);
result.1.tab = v14;
result.1.data = v16;
result.0 = v15;
return result;
}
PoC
처음 main_NewEncSocket
에서 사용자의 입력값에 대한 salsa20
암호화를 수행하고 암호문을 사용자에게 돌려준다. 이 때 시드 값이 p_main_libcRand->seed = (int)(v44 >> 33) % 256;
로 정의되므로 0부터 255 사이의 값만을 가진다. 때문에 입력 평문과 주어진 암호문 시드 값과 이를 이용하여 생성한 key
와 nonce
를 구할 수 있다.
def get_key_nonce(plain,cipher):
key= bytearray()
nonce = bytearray()
for seed in range(256):
rand_gen = LibcRand(seed)
for _ in range(32):
key.append(rand_gen.next() & 0xFF) # 하위 8비트 사용
for _ in range(8): # 8번 반복
nonce.append(rand_gen.next() & 0xFF) # 하위 8비트 사용
salsa = Salsa20.new(key=key, nonce=nonce)
cipher_test = salsa.encrypt(plain)
if cipher_test == cipher:
#print(f"seed found : {seed}")
break
else:
key= bytearray()
nonce = bytearray()
return rand_gen,key,nonce
key
와 rand_gen
을 main_NewEncSocket
에서 재활용하는 것을 확인하였으므로 이를 이용하여 암호화시 exit
이 되는 사용자 입력값을 찾을 수 있고, 그 값을 송신하면 무한루프를 탈출하고 flag
를 수신할 수 있다.
def make_exit(rand_gen,key,nonce):
nonce2 = bytearray()
for _ in range(8): # 8번 반복
nonce2.append(rand_gen.next() & 0xFF) # 하위 8비트 사용
salsa = Salsa20.new(key=key, nonce=nonce2)
#print(nonce2)
exit_command = salsa.decrypt(b"exit")
return exit_command
...
exit_command = make_exit(rand_gen,key,nonce)
conn.send(exit_command)
encrypted_flag = conn.recv(1024)
flag
또한 동일한 방식으로 salsa20
복호화하면 평문 값을 얻을 수 있다.
부록(PoC.py)
from pwn import *
from Crypto.Cipher import Salsa20
class LibcRand:
def __init__(self, seed):
self.seed = seed & 0xFFFFFFFF
def next(self): #LCG
self.seed = (1103515245 * self.seed + 12345) & 0xFFFFFFFF
return self.seed
def make_exit(rand_gen,key,nonce):
nonce2 = bytearray()
for _ in range(8): # 8번 반복
nonce2.append(rand_gen.next() & 0xFF) # 하위 8비트 사용
salsa = Salsa20.new(key=key, nonce=nonce2)
#print(nonce2)
exit_command = salsa.decrypt(b"exit")
return exit_command
def get_key_nonce(plain,cipher):
key= bytearray()
nonce = bytearray()
for seed in range(256):
rand_gen = LibcRand(seed)
for _ in range(32):
key.append(rand_gen.next() & 0xFF) # 하위 8비트 사용
for _ in range(8): # 8번 반복
nonce.append(rand_gen.next() & 0xFF) # 하위 8비트 사용
salsa = Salsa20.new(key=key, nonce=nonce)
cipher_test = salsa.encrypt(plain)
if cipher_test == cipher:
#print(f"seed found : {seed}")
break
else:
key= bytearray()
nonce = bytearray()
return rand_gen,key,nonce
def main_NewEncSocket(r,user_input):
r.send(user_input)
response = r.recv(1024)
return response
if __name__=='__main__':
host = localhost
port = 7777 #임의 포트
conn = remote(host,port)
initial_input = b"01234567890123456789012345678901" # length:32
initial_response = main_NewEncSocket(conn,initial_input)
rand_gen,key,nonce = get_key_nonce(initial_input,initial_response)
exit_command = make_exit(rand_gen,key,nonce)
conn.send(exit_command)
encrypted_flag = conn.recv(1024)
nonce3 = bytearray()
for _ in range(8): # 8번 반복
nonce3.append(rand_gen.next() & 0xFF) # 하위 8비트 사용
salsa = Salsa20.new(key=key, nonce=nonce3)
flag = salsa.decrypt(encrypted_flag)
print(flag)