AXIOM CTF 2026 Writeup
Reverse Engineering
1. [Baby] Coffee Break
| Mức độ: Easy | Kiến trúc: ELF64 | Công cụ: IDA Freeware 9.3, Python |
Khảo sát ban đầu (Initial Recon)
Mở file bằng IDA Freeware 9.3 để phân tích tĩnh. Bấm phím F5 để xem mã giả (pseudo-code) của hàm main. Sau khi dọn dẹp và đổi tên các biến/hàm cho dễ đọc, ta có được luồng chương trình như sau:
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
__int64 __fastcall main(int a1, char **a2, char **a3)
{
char input[64]; // [rsp+0h] [rbp-40h] BYREF
putchar(10);
puts(" ================================");
puts(" KOFEVARAKA BODROST-3000");
puts(" Laboratoriya Pina");
puts(" ================================\n");
puts(" [!] Apparat zablokirovan");
printf(" [?] Vvedite parol: ");
if ( !fgets(input, 64, stdin) )
return 0;
input[strcspn(input, "\n")] = 0;
puts("\n *brrr... whirrr...*\n");
if ( check(input) )
{
puts(" [+] Parol prinyat!");
puts(" [+] Zavarivayu kofe...");
puts(" [+] Losyash spasen!\n");
}
else
{
puts(" [-] Nepravilniy parol!");
puts(" [-] Losyash prodolzhaet besedovat s globusom...\n");
}
return 0;
}
Phân tích luồng main:
- Chương trình nhận input của người dùng thông qua hàm
fgets(tối đa 64 ký tự). - Cắt bỏ ký tự xuống dòng
\n. - Đưa chuỗi input vào hàm
check(tên gốc làsub_401244). Nếu hàm này trả về true (khác 0), mật khẩu được chấp nhận và in ra dòng chữ"Losyash spasen!".
Phân tích thuật toán kiểm tra (The Checking Logic)
Tiếp tục đi sâu vào hàm check.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
_BOOL8 __fastcall check(const char *passwd)
_BOOL8 __fastcall check(const char *input)
{
unsigned __int8 right; // [rsp+16h] [rbp-Ah]
unsigned __int8 left; // [rsp+17h] [rbp-9h]
int i; // [rsp+18h] [rbp-8h]
unsigned __int16 state; // [rsp+1Eh] [rbp-2h]
if ( strlen(input) != 28 )
return 0;
state = 4919;
for ( i = 0; i <= 13; ++i )
{
left = input[i];
right = input[27 - i];
if ( !(unsigned int)check_char_pair(left, right, (unsigned int)i, state) )
return 0;
state = update_state(state, left, right);
}
return state == 30740;
}
Phân tích logic:
- Kiểm tra độ dài: Chuỗi nhập vào bắt buộc phải dài đúng 28 ký tự.
- Khởi tạo trạng thái: Biến
stateban đầu được gán bằng 4919. - Vòng lặp đối xứng: Chương trình duyệt qua 14 cặp ký tự (đầu và cuối chuỗi chập lại) thông qua biến
itừ 0 đến 13.- Lấy
left = input[i]vàright = input[27 - i]. - Gọi hàm
check_char_pairkiểm tra cặp ký tự này cùng với biếnstatehiện tại. - Cập nhật lại
statecho vòng lặp tiếp theo.
- Lấy
- Điều kiện thắng: Sau khi qua hết vòng lặp, biến state cuối cùng phải bằng 30740.
Tiếp tục phân tích hàm check_char_pair và hàm update_state:
1
2
3
4
_BOOL8 __fastcall check_char_pair(char left, char right, int i, __int16 state)
{
return left + (_BYTE)i + (_BYTE)state == byte_402008[i] && right - (_BYTE)i - HIBYTE(state) == byte_402018[i];
}
1
2
3
4
__int64 __fastcall update_state(__int16 state, unsigned __int8 left, unsigned __int8 right)
{
return (unsigned __int16)((8 * (right + (left ^ state))) | ((unsigned __int16)(right + (left ^ state)) >> 13));
}
Đọc vị thuật toán:
- Hàm kiểm tra cặp ký tự sẽ so sánh giá trị tính toán từ
left,right,i, vàstatevới 2 mảng tĩnh được lưu trong file làbyte_402008vàbyte_402018. - Cụ thể:
left + i + LOBYTE(state) == byte_402008[i]right - i - HIBYTE(state) == byte_402018[i]
- Hàm cập nhật trạng thái bản chất là một phép dịch xoay trái (Rotate Left - ROL) 3 bit đối với số 16-bit.
Vì thuật toán này mang tính chất tuyến tính, không phụ thuộc vào các ký tự ở tương lai, ta hoàn toàn có thể đảo ngược phép toán để tìm lại chuỗi gốc từ đầu đến cuối.
Trích xuất dữ liệu và Viết kịch bản giải mã (Solve Script)
Vào section .rodata trong IDA, mình dùng tính năng Export Data (Shift + E) để lấy dữ liệu thô của 2 mảng byte_402008 và byte_402018 ra (mỗi mảng 14 bytes).
Từ công thức ở trên, rút ra phép tính ngược:
left = byte_402008[i] - i - LOBYTE(state)right = byte_402018[i] + i + HIBYTE(state)
Script python:
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
def ROL16(x, shift):
# Dịch xoay trái 16-bit
x = x & 0xFFFF
return ((x << shift) | (x >> (16 - shift))) & 0xFFFF
# Dữ liệu trích xuất từ .rodata của IDA
byte_402008 = [0x98, 0x11, 0x77, 0x39, 0x4F, 0x49, 0x33, 0x25, 0x85, 0x43, 0x9C, 0xF3, 0x42, 0x37]
byte_402018 = [0x6A, 0x82, 0x39, 0x6C, 0x3C, 0x29, 0x62, 0x16, 0x1D, 0x94, 0xA1, 0x5A, 0xB6, 0xD3]
state = 4919 # Khởi tạo từ hàm main
passwd = ["_"] * 28 # Chuỗi mật khẩu dài 28 ký tự
for i in range(14):
low_state = state & 0xFF
high_state = (state >> 8) & 0xFF
# Tính toán lại ký tự left và right
left = (byte_402008[i] - i - low_state) & 0xFF
right = (byte_402018[i] + i + high_state) & 0xFF
# Điền vào mảng passwd
passwd[i] = chr(left)
passwd[27 - i] = chr(right)
# Cập nhật state giống hệt hàm của chương trình
temp = (right + (left ^ state)) & 0xFFFF
state = ROL16(temp, 3)
# In kết quả
final_password = "".join(passwd)
print(f"{final_password}")
Kết quả
Chạy script, ta thu được mật khẩu chính xác và trạng thái cuối cùng khớp hoàn hảo với 30740.
Flag: axiom{l0sy4sh_n33ds_c0ff33!}
2. [Baby] Микроскоп Лосяша (Microscope)
| Mức độ: Easy | Kiến trúc: ELF 64-bit x86_64 (Stripped) | Công cụ: IDA, Python, Linux Terminal |
Khảo sát ban đầu
file cho biết đây là ELF 64-bit đã bị stripped. Chạy thử thì chương trình thoát ngay, không in ra gì cả.
Mở IDA, nhảy vào main và F5:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
__int64 __fastcall main(int a1, char **a2, char **a3)
{
_BYTE *v3; // rdx
_BYTE *v4; // r13
_BYTE *v5; // rbp
__int64 v6; // rbx
char v7; // al
char v8; // r12
__int64 v9; // rcx
__int64 v10; // rdx
bool v11; // cf
unsigned __int64 v12; // rdx
__int64 v13; // rax
unsigned __int64 v14; // r15
_BYTE *v15; // rax
signed __int64 v16; // rcx
_BYTE *v17; // r14
_BYTE v19[84]; // [rsp+20h] [rbp-58h]
v3 = nullptr;
v4 = nullptr;
v5 = nullptr;
v6 = 0;
*(__m128i *)v19 = _mm_load_si128((const __m128i *)&xmmword_2050);
*(__m128i *)&v19[12] = _mm_load_si128((const __m128i *)&xmmword_2060);
do
{
while ( 1 )
{
v7 = v19[v6] & 0x7F;
v8 = v7;
if ( v5 == v3 )
break;
v6 += 3;
*v5++ = v7;
if ( v6 == 30 )
goto LABEL_14;
}
v9 = v5 - v4;
if ( v5 - v4 == 0x7FFFFFFFFFFFFFFFLL )
std::__throw_length_error("vector::_M_realloc_append");
v10 = 1;
if ( v9 )
v10 = v5 - v4;
v11 = __CFADD__(v9, v10);
v12 = v9 + v10;
if ( v11 )
{
v14 = 0x7FFFFFFFFFFFFFFFLL;
}
else
{
v13 = 0x7FFFFFFFFFFFFFFFLL;
if ( v12 <= 0x7FFFFFFFFFFFFFFFLL )
v13 = v12;
v14 = v13;
}
v15 = (_BYTE *)operator new(v14);
v16 = v5 - v4;
v17 = v15;
v15[v16] = v8;
v5 = &v15[v5 - v4 + 1];
if ( v16 > 0 )
{
memcpy(v15, v4, v16);
}
else if ( !v4 )
{
goto LABEL_13;
}
operator delete(v4);
LABEL_13:
v6 += 3;
v3 = &v17[v14];
v4 = v17;
}
while ( v6 != 30 );
LABEL_14:
if ( v4 == v5 || v4 )
operator delete(v4);
return 0;
}
Nhìn qua thì main làm mấy việc: dùng _mm_load_si128 nạp hai khối dữ liệu tĩnh (xmmword_2050 và xmmword_2060) vào một mảng cục bộ, duyệt qua mảng với bước nhảy 3, AND từng byte với 0x7F rồi đẩy vào std::vector — kết quả ra một chuỗi 10 ký tự. Sau đó delete luôn và return 0.
Mô phỏng lại (hoặc đặt breakpoint trước lệnh delete) thu được: !TDw*]4o (hex: 21 54 11 44 77 2A 5D 34 10 6F). Thoạt nhìn có vẻ là flag, nhưng submit thì sai.
Bẫy
Nhìn kỹ lại dữ liệu tại xmmword_2050 và xmmword_2060: A1 B2 C3 D4 E5 F6 11 22 33 44 55... — rõ ràng là một dãy số đếm tăng dần giả tạo, không có giá trị thực. Tác giả dựng cái main này như một cái bẫy, đủ để ai debug nhanh hoặc viết script theo luồng chính thì tưởng đã xong.
Kiểm tra thêm .init_array và chạy strings cũng không thấy gì khả nghi — chỉ là các hàm dọn dẹp mặc định của GCC C++.
Tên bài là “Микроскоп” (Kính hiển vi) — gợi ý khá thẳng: phải soi kỹ hơn.
Tìm hàm ẩn
Thay vì tiếp tục bới main, mở bảng Functions (Shift+F3) trong IDA và lọc bỏ các hàm thư viện. Một cái tên nổi lên: sub_1380 khả nghi.
Decompile ra:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
__int64 __fastcall sub_1380(char *s)
{
char *v1; // r14
char v2; // si
bool v3; // zf
__int64 v4; // rax
_BYTE *v5; // rbx
char v6; // si
std::ostream *v7; // rax
__int64 (__fastcall *v9)(); // rax
char v10[57]; // [rsp+Fh] [rbp-39h] BYREF
v1 = (char *)&unk_4080;
std::__ostream_insert<char,std::char_traits<char>>(std::cout, &unk_2008, 39);
do
{
while ( 1 )
{
v2 = *v1 ^ s[(v1 - (char *)&unk_4080) % strlen(s)];
v3 = *(_QWORD *)((char *)&std::cout[2] + *(_QWORD *)(std::cout[0] - 24LL)) == 0;
v10[0] = v2;
if ( v3 )
break;
++v1;
std::__ostream_insert<char,std::char_traits<char>>(std::cout, v10, 1);
if ( &unk_40AB == (_UNKNOWN *)v1 )
goto LABEL_5;
}
++v1;
std::ostream::put((std::ostream *)std::cout, v2);
}
while ( &unk_40AB != (_UNKNOWN *)v1 );
LABEL_5:
v4 = *(_QWORD *)(std::cout[0] - 24LL);
v5 = *(_BYTE **)((char *)&std::cout[30] + v4);
if ( !v5 )
std::__throw_bad_cast();
if ( v5[56] )
{
v6 = v5[67];
}
else
{
std::ctype<char>::_M_widen_init(*(_QWORD *)((char *)&std::cout[30] + v4));
v6 = 10;
v9 = *(__int64 (__fastcall **)())(*(_QWORD *)v5 + 48LL);
if ( v9 != std::ctype<char>::do_widen )
v6 = ((__int64 (__fastcall *)(_BYTE *, __int64))v9)(v5, 10);
}
v7 = (std::ostream *)std::ostream::put((std::ostream *)std::cout, v6);
return std::ostream::flush(v7);
}
Hàm này chuẩn bị gọi std::cout để in ra hai thứ:
unk_2008: các byte UTF-8D0 A0 D0 B0..., dịch ra là"Расшифрованный флаг: "(tức là “Decrypted flag: “).unk_4080: mảng 43 byte lộn xộn — đây là ciphertext của flag.
Lúc này mọi thứ khớp với nhau: chuỗi 10 ký tự !TDw*]4o mà main tạo ra không phải output vô nghĩa — đó là key giải mã. Còn sub_5380 giữ ciphertext thật sự nhưng không bao giờ được gọi. Chỉ cần XOR lặp (repeating-key XOR) ciphertext với key đó là ra flag.
Solve Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Mảng byte Flag bị mã hóa
encrypted_flag = [
0x60, 0x2C, 0x78, 0x2B, 0x1A, 0x51, 0x6C, 0x06, 0x25, 0x58,
0x7E, 0x60, 0x4E, 0x26, 0x46, 0x46, 0x31, 0x05, 0x20, 0x01,
0x7E, 0x39, 0x20, 0x27, 0x05, 0x1A, 0x2E, 0x57, 0x20, 0x1F,
0x12, 0x0B, 0x20, 0x71, 0x28, 0x44, 0x6D, 0x03, 0x78, 0x5E,
0x4F, 0x62, 0x6C
]
# Chìa khóa 10 byte sinh ra từ hàm main
key = [0x21, 0x54, 0x11, 0x44, 0x77, 0x2A, 0x5D, 0x34, 0x10, 0x6F]
flag = ""
# Giải mã bằng XOR lặp (Repeating-key XOR)
for i in range(len(encrypted_flag)):
decrypted_char = encrypted_flag[i] ^ key[i % len(key)]
flag += chr(decrypted_char)
print("Flag:", flag)
Kết quả
Flag: Axiom{1257_4_b1ll10n_m1cr0sc0p3_15_n07h1n6}
3. Проверщик лосяша (Losyash’s Checker)
| Độ khó: Easy | Kiến trúc: ELF64 | Tool: IDA, Python, Linux Terminal |
Nhìn sơ qua file
Lỗi error while loading shared libraries: libcrypto.so.1.1 — thiếu thư viện OpenSSL 1.1. Cái này thực ra là gợi ý khá hay: chương trình dùng OpenSSL, tức là bên trong chắc chắn có gọi hàm hash hoặc crypto chuẩn, không phải tự chế.
Đọc logic kiểm tra
Mở trong IDA, vào hàm kiểm tra input được gọi từ main.
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
40
41
42
43
44
45
46
47
48
49
50
51
__int64 __fastcall check_user_input(const char *a1)
{
unsigned int v2; // [rsp+1Ch] [rbp-174h] BYREF
char v3[64]; // [rsp+20h] [rbp-170h] BYREF
char s1[130]; // [rsp+60h] [rbp-130h] BYREF
char v5[9]; // [rsp+E2h] [rbp-AEh] BYREF
char dest[5]; // [rsp+EBh] [rbp-A5h] BYREF
char *s2[8]; // [rsp+F0h] [rbp-A0h]
_QWORD v8[10]; // [rsp+130h] [rbp-60h]
unsigned int j; // [rsp+184h] [rbp-Ch]
unsigned __int64 i; // [rsp+188h] [rbp-8h]
v8[0] = "faCT";
v8[1] = "paX6";
v8[2] = "eMGP";
v8[3] = "ts4k";
v8[4] = "vtfD";
v8[5] = "PETQ";
v8[6] = "kNxY";
v8[7] = "Zoa8";
s2[0] = "519ef2c4dc55d3556abf0c41eb1e3c6fdb8f96590a9fcdae9940a17ba9dfc3664ec4de37feeb485f772f6589cdf44201ec92997bd254c1"
"755bf812761f992435";
s2[1] = "730a5983f1d3af2ca2dedc3ed87fdb0dce96bcc3356cc195658ac10b299ab00364b8a0e2e103c93a703dc9d1fe6e3d186e1a071a290d09"
"e9c67ba5891a2e6188";
s2[2] = "d8e14db2e6f41ba7e45e1ade2a62174af59bf3a943c7116d364142b240b9908ac9e88b6298a5f7ea6a082f7fd52668d46cd23cf085635e"
"78a8500c48f3dbf6a8";
s2[3] = "efd0214ac40851fbc4e203eac495f768c918c1d2708dda246442f507b3d05f36c6817aa2b61a5d29d33c3caa76acc69a7b8f4806611775"
"0808ff28eb8329b4fc";
s2[4] = "34f501ced23a6b807cdd375dbe31ead23f2091011614f74ee44361e4cb2730523b91982bad19f6942b3a1505340224b3370ef4848db4f4"
"dcbda32017769270be";
s2[5] = "01de645a8cf584aaae8ff6d2e264fd5193a8aec19d30c935344a84885b250c81c9858de3be220e659f04586ffba90f8cab978e55130683"
"5abdabed7eca9a1803";
s2[6] = "c4a653abc68a83d636faaa85496122aa54f6152cff1659183bafd39c2fbc5ffde7f66b142a718089d8600ec1f9a3b1854ddbbd33c923d2"
"d9087284e2d275e7c0";
s2[7] = "02857289f5ae4501a7d50c2615a2e1b8182a937c723ea2fa071e9e34bf86866b5a51902d726d1bbe7c824ea2873e8d3ef227e34bbe62c7"
"b826288e9ec42092b6";
v8[9] = strlen(a1);
for ( i = 0; i <= 0x1F; i += 4LL )
{
strncpy(dest, &a1[i], 4u);
dest[4] = 0;
snprintf(v5, 9u, "%s%s", dest, (const char *)v8[i >> 2]);
hash_block(v5, v3, &v2);
for ( j = 0; j < v2; ++j )
sprintf(&s1[2 * j], "%02x", (unsigned __int8)v3[j]);
s1[2 * v2] = 0;
if ( strncmp(s1, s2[i >> 2], 0x40u) )
return 0;
}
return 1;
}
Nhìn qua thì luồng chạy như sau:
1. Chia input thành từng khúc nhỏ
Không băm cả chuỗi một lần mà dùng vòng for chạy 8 lần (biến i từ 0 đến 0x1F, bước nhảy 4). Mỗi lần lấy 4 ký tự, tổng cộng mật khẩu dài đúng 32 ký tự.
2. Gắn salt vào
Mỗi khúc 4 ký tự bị strncpy cắt ra, rồi snprintf nối thêm một chuỗi salt 4 ký tự cố định lấy từ mảng v8 (kiểu như "faCT", "paX6"…). Kết quả là một chuỗi 8 ký tự.
3. Băm
Chuỗi 8 ký tự đó được đẩy vào hash_block. Hàm này trả về mảng byte, rồi format thành hex lưu vào s1.
4. So sánh — và đây là chỗ quan trọng
strncmp(s1, s2[i >> 2], 0x40u) — chỉ so 64 ký tự đầu của chuỗi hex, không so hết.
Nếu hash cả 32 ký tự một lần thì không crack được. Nhưng vì input bị cắt thành từng khúc 4 ký tự độc lập, không gian tìm kiếm thu nhỏ lại rất nhiều. Brute-force 4 ký tự cho mỗi vòng là chuyện nhỏ — đây là kiểu Divide and Conquer kinh điển.
Xem hàm hash dùng thuật toán gì
1
2
3
4
5
6
7
8
9
10
11
12
13
14
__int64 __fastcall hash_block(const char *a1, __int64 a2, __int64 a3)
{
size_t v3; // rax
__int64 v6; // [rsp+20h] [rbp-10h]
__int64 v7; // [rsp+28h] [rbp-8h]
v7 = EVP_MD_CTX_new();
v6 = EVP_sha3_512();
EVP_DigestInit_ex(v7, v6, 0);
v3 = strlen(a1);
EVP_DigestUpdate(v7, a1, v3);
EVP_DigestFinal_ex(v7, a2, a3);
return EVP_MD_CTX_free(v7);
}
Hàm dùng EVP API của OpenSSL. Dòng quan trọng nhất:
1
v6 = EVP_sha3_512();
Là SHA3-512, không phải SHA-512 (SHA-2). Cả hai đều cho ra chuỗi hex 128 ký tự nhưng kết quả hoàn toàn khác nhau. Việc chỉ so 64 ký tự đầu có thể là bẫy để người chơi nhầm sang SHA-256 (cũng cho ra 64 ký tự hex).
Script giải
Viết script Python:
- Duyệt 8 chunk
- Với mỗi chunk, thử hết các tổ hợp 4 ký tự in được
- Nối với salt tương ứng, băm SHA3-512
- So 64 ký tự đầu với giá trị đích trong
s2
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import hashlib
import string
import itertools
import time
import sys
import logging
from multiprocessing import Pool, cpu_count
# Cấu hình logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Mảng chứa 128 ký tự hex gốc từ IDA
TARGET_HASHES = [
"519ef2c4dc55d3556abf0c41eb1e3c6fdb8f96590a9fcdae9940a17ba9dfc3664ec4de37feeb485f772f6589cdf44201ec92997bd254c1755bf812761f992435",
"730a5983f1d3af2ca2dedc3ed87fdb0dce96bcc3356cc195658ac10b299ab00364b8a0e2e103c93a703dc9d1fe6e3d186e1a071a290d09e9c67ba5891a2e6188",
"d8e14db2e6f41ba7e45e1ade2a62174af59bf3a943c7116d364142b240b9908ac9e88b6298a5f7ea6a082f7fd52668d46cd23cf085635e78a8500c48f3dbf6a8",
"efd0214ac40851fbc4e203eac495f768c918c1d2708dda246442f507b3d05f36c6817aa2b61a5d29d33c3caa76acc69a7b8f48066117750808ff28eb8329b4fc",
"34f501ced23a6b807cdd375dbe31ead23f2091011614f74ee44361e4cb2730523b91982bad19f6942b3a1505340224b3370ef4848db4f4dcbda32017769270be",
"01de645a8cf584aaae8ff6d2e264fd5193a8aec19d30c935344a84885b250c81c9858de3be220e659f04586ffba90f8cab978e551306835abdabed7eca9a1803",
"c4a653abc68a83d636faaa85496122aa54f6152cff1659183bafd39c2fbc5ffde7f66b142a718089d8600ec1f9a3b1854ddbbd33c923d2d9087284e2d275e7c0",
"02857289f5ae4501a7d50c2615a2e1b8182a937c723ea2fa071e9e34bf86866b5a51902d726d1bbe7c824ea2873e8d3ef227e34bbe62c7b826288e9ec42092b6"
]
SALTS = ["faCT", "paX6", "eMGP", "ts4k", "vtfD", "PETQ", "kNxY", "Zoa8"]
# Bộ ký tự chuẩn thường gặp trong CTF
CHARSET = string.ascii_letters + string.digits + "{}_-!@#?"
def crack_chunk(args):
chunk_index, target_hash, salt = args
logger.info(f"[*] Đang xử lý Chunk {chunk_index + 1}/8 (Salt: {salt})...")
target_prefix = target_hash[:64]
for combo in itertools.product(CHARSET, repeat=4):
guess = "".join(combo)
test_string = guess + salt
hashed = hashlib.sha3_512(test_string.encode('utf-8')).hexdigest()
if hashed[:64] == target_prefix:
logger.info(f"[+] TÌM THẤY: '{guess}'")
return guess
logger.warning("[-] Thất bại! Không tìm thấy kết quả phù hợp.")
return None
def main():
logger.info("=== BẮT ĐẦU CHƯƠNG TRÌNH KHÔI PHỤC FLAG (SHA3-512) ===")
start_time = time.time()
with Pool(processes=cpu_count()) as pool:
args = [(i, TARGET_HASHES[i], SALTS[i]) for i in range(8)]
results = pool.map(crack_chunk, args)
if None in results:
logger.error("\n!!! Có lỗi xảy ra, không tìm thấy chuỗi khớp.")
sys.exit(1)
recovered_flag = "".join(results)
end_time = time.time()
logger.info("\n" + "="*50)
logger.info(f"🎉 FLAG HOÀN CHỈNH: {recovered_flag}")
logger.info(f"⏱️ Thời gian thực thi: {round(end_time - start_time, 2)} giây")
logger.info("="*50)
if __name__ == "__main__":
main()
Kết quả
Ghép lại được chuỗi hex 32 ký tự: 7c9d6451fddb425296ffea34132f6f9e
Bọc vào format của giải:
1
Flag: axiom{7c9d6451fddb425296ffea34132f6f9e}
4. [RE 4]
| Mức độ: [Độ khó] | Kiến trúc: [Kiến trúc] | Công cụ: [Công cụ] |
Pwn
1. ЛАВКА ФАНТИКОВ ЁЖИКА (fantiky_shop)
| Mức độ: Medium | Kiến trúc: ELF 64-bit | Công cụ: IDA, Python (pwntools) |
Nhìn qua bài trước
Chạy thử thì ra menu bằng tiếng Nga:
1
2
3
4
5
6
7
—— ЛАВКА ФАНТИКОВ ЁЖИКА ——
1. Добавить фантик в коллекцию
2. Изменить описание
3. Полюбоваться фантиком
4. Выбросить фантик
5. Уйти
>
Dịch ra thì các chức năng gồm:
- Добавить (Add): Thêm một “fantik” mới (gồm Index, Name, Content)
- Изменить (Edit): Sửa Content
- Полюбоваться (View): In thông tin fantik ra màn hình
- Выбросить (Free): Giải phóng bộ nhớ
- Уйти (Exit): Thoát
Libc 2.31 nên Tcache đã có mặt.
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
int free_fantik()
{
__int64 v0; // rax
int v2; // [rsp+Ch] [rbp-4h] BYREF
printf(&byte_2178);
LODWORD(v0) = __isoc99_scanf(&unk_202A, &v2);
if ( (_DWORD)v0 == 1 )
{
clear_buffer();
LODWORD(v0) = v2;
if ( v2 >= 0 )
{
LODWORD(v0) = v2;
if ( v2 <= 9 )
{
v0 = *((_QWORD *)&collection + v2);
if ( v0 )
{
free(*((void **)&collection + v2));
LODWORD(v0) = puts(asc_21B8);
}
}
}
}
return v0;
}
Mở IDA lên phân tích thì thấy ngay lỗi Use-After-Free (UAF) khá kinh điển: sau khi gọi Free, con trỏ trỏ đến vùng heap không bị xóa (dangling pointer). Tức là vẫn có thể gọi View để đọc, hoặc Edit để ghi lên vùng nhớ đã free rồi.
Khai thác
Chia làm 3 giai đoạn. Nghe thì đơn giản nhưng thực tế gặp khá nhiều bẫy I/O và cấu trúc bộ nhớ mà phải debug mãi mới xử lý xong.
Giai đoạn 1: Leak địa chỉ Libc
Trước tiên cần biết Libc Base để tính địa chỉ các hàm/hook. Cách làm là lợi dụng Unsorted Bin.
- Cấp phát 9 chunk (index 0 → 8).
- Free 8 chunk đầu (0 → 7). Với Libc 2.31, Tcache chứa tối đa 7 chunk — tức 7 chunk đầu lấp đầy Tcache, chunk thứ 8 (index 7) bị đẩy vào Unsorted Bin. Lúc này hai con trỏ
fdvàbkcủa chunk 7 trỏ thẳng vàomain_arenatrong vùng nhớ Libc. - Dùng lỗi UAF, gọi View vào chunk 7 → địa chỉ
main_arenabị in ra.
Bẫy gặp phải: Trên server thực tế, ASLR thỉnh thoảng cấp phát Libc ở địa chỉ bắt đầu bằng byte 0x7e thay vì 0x7f như thường thấy trên máy local. Cộng thêm độ trễ mạng khiến recvuntil() hoạt động lung tung.
Cách xử lý: Dùng dấu ': ' trong menu làm mỏ neo để đồng bộ, rồi dùng split thay vì regex:
1
2
3
4
5
raw_leak = view(7)
idx = raw_leak.find(b': ')
if idx != -1:
leak_bytes = raw_leak[idx+2:].split(b'\n')[0]
libc_leak = u64(leak_bytes.ljust(8, b'\x00'))
Giai đoạn 2: Tcache Poisoning
Có Libc Base rồi thì tính được __free_hook và system(). Mục tiêu bây giờ là ghi system() vào __free_hook.
- Chunk 6 đang nằm trên đỉnh Tcache. Dùng Edit để ghi địa chỉ
__free_hookvào con trỏfdcủa chunk 6. - Gọi Add hai lần: lần 1 trả về chunk 6, lần 2 hệ thống bị lừa → cấp phát chunk ngay tại địa chỉ
__free_hook. - Ghi địa chỉ
system()vào đó.
Bẫy gặp phải: Script bị deadlock tại hàm Edit. Ngồi trace lại mới phát hiện ra: tất cả các hàm Add, View, Free khi hỏi input đều kết thúc bằng dấu ': ' — nhưng riêng Edit thì không có. Dùng p.sendlineafter(b': ', content) trong Edit là script treo vĩnh viễn vì chờ ký tự không bao giờ xuất hiện.
Cách xử lý: Bỏ qua việc chờ prompt ở Edit, thay bằng time.sleep(0.2) rồi gửi payload thẳng.
Giai đoạn 3: Lấy Shell (không làm crash server)
Ý tưởng ban đầu: Add một chunk mới chứa /bin/sh\x00, rồi Free nó để system('/bin/sh') chạy. Nhưng thực tế vừa gọi Add là server sập ngay — văng EOFError.
Lý do: Giai đoạn 2 đã phá nát metadata của heap khi poisoning Tcache. Gọi malloc thêm lúc này là chương trình crash trước khi kịp làm gì.
Giải pháp: Không dùng malloc nữa. Nhớ lại hồi đầu còn chunk số 8 vẫn còn sống, chưa bị Free. Chỉ cần Edit chunk 8 thành /bin/sh\x00, rồi gọi Free lên nó là xong — __free_hook (đã trỏ vào system()) tự kích hoạt system('/bin/sh').
Solve Script
1
Kết quả
Script chạy thông. Payload luồn qua hết các bẫy I/O, ghi đè __free_hook thành công, và trả về interactive shell.
Web
1. [Baby] Библиотека Лосяша
| Mức độ: Easy | Thể loại: Local File Inclusion (LFI) | Công cụ: Trình duyệt, curl |
Khảo sát ban đầu (Initial Recon)
Trang web có giao diện một thư viện sách với 3 đầu sách có thể đọc. Khi click vào từng cuốn, URL thay đổi theo dạng:
1
2
3
read.php?page=books/book1.txt
read.php?page=books/book2.txt
read.php?page=books/book3.txt
Tham số page nhận trực tiếp đường dẫn file rồi đưa vào hàm include() của PHP — đây là dấu hiệu rõ ràng của lỗ hổng Local File Inclusion (LFI).
Đọc source code (Source Disclosure)
Dùng PHP filter wrapper để đọc source của read.php mà không bị thực thi:
1
read.php?page=php://filter/convert.base64-encode/resource=read.php
Decode base64 thu được source:
1
2
3
4
5
6
7
8
9
10
<?php
$page = $_GET['page'] ?? 'books/book1.txt';
// "Security" check - blocks only the obvious
if (strpos($page, '/etc/passwd') !== false) {
die("<b>Взлом обнаружен!!1</b><br>Лосяш недоволен.");
}
?>
...
<pre><?php include($page); ?></pre>
Filter chỉ chặn chuỗi /etc/passwd, không có bất kỳ kiểm tra nào khác — hoàn toàn có thể bypass bằng path traversal tùy ý.
Tìm PART 2 của flag
Thử đọc flag.txt ở thư mục cha:
1
read.php?page=../../flag.txt
Trả về:
1
PART 2: 45H_l1bR4rY_cxfdicllqm7t}
Tìm PART 1 của flag qua robots.txt
Truy cập robots.txt để tìm thêm thông tin ẩn:
1
http://tasks.4x10m.ru:20786/robots.txt
Kết quả:
1
2
3
4
User-agent: *
Disallow: /config.php
Disallow: /admin/
Disallow: /backup/
File config.php đáng ngờ — truy cập trực tiếp thì trả về trang trống (file PHP chỉ chứa code, không echo gì). Dùng PHP filter để đọc source:
1
read.php?page=php://filter/convert.base64-encode/resource=config.php
Decode base64, thu được PART 1 của flag ẩn trong biến PHP bên trong file.
Kết quả
Ghép 2 phần lại:
- PART 1:
axiom{B451c_LF1_1N7o_lOcU - PART 2:
45H_l1bR4rY_cxfdicllqm7t}
Flag: axiom{B451c_LF1_1N7o_lOcU45H_l1bR4rY_cxfdicllqm7t}










