Post

AXIOM CTF 2026 Writeup

AXIOM CTF 2026 Writeup

Reverse Engineering

1. [Baby] Coffee Break

Mức độ: EasyKiến trúc: ELF64Công cụ: IDA Freeware 9.3, Python

Khảo sát ban đầu (Initial Recon)

Try

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:

  1. Kiểm tra độ dài: Chuỗi nhập vào bắt buộc phải dài đúng 28 ký tự.
  2. Khởi tạo trạng thái: Biến state ban đầu được gán bằng 4919.
  3. 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 i từ 0 đến 13.
    • Lấy left = input[i]right = input[27 - i].
    • Gọi hàm check_char_pair kiểm tra cặp ký tự này cùng với biến state hiện tại.
    • Cập nhật lại state cho vòng lặp tiếp theo.
  4. Đ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à state với 2 mảng tĩnh được lưu trong file là byte_402008byte_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).

byte

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

Flag: axiom{l0sy4sh_n33ds_c0ff33!}


2. [Baby] Микроскоп Лосяша (Microscope)

Mức độ: EasyKiến trúc: ELF 64-bit x86_64 (Stripped)Công cụ: IDA, Python, Linux Terminal

Khảo sát ban đầu

try

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_2050xmmword_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

xmmword

Nhìn kỹ lại dữ liệu tại xmmword_2050xmmword_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-8 D0 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.

unk4080

Lúc này mọi thứ khớp với nhau: chuỗi 10 ký tự !TDw*]4omain 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

Flag: Axiom{1257_4_b1ll10n_m1cr0sc0p3_15_n07h1n6}


3. Проверщик лосяша (Losyash’s Checker)

Độ khó: EasyKiến trúc: ELF64Tool: IDA, Python, Linux Terminal

Nhìn sơ qua file

khaosat

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();

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ả

flag

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 độ: MediumKiến trúc: ELF 64-bitCông cụ: IDA, Python (pwntools)

Nhìn qua bài trước

khaosat

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.

  1. Cấp phát 9 chunk (index 0 → 8).
  2. 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ỏ fdbk của chunk 7 trỏ thẳng vào main_arena trong vùng nhớ Libc.
  3. Dùng lỗi UAF, gọi View vào chunk 7 → địa chỉ main_arena bị 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_hooksystem(). Mục tiêu bây giờ là ghi system() vào __free_hook.

  1. Chunk 6 đang nằm trên đỉnh Tcache. Dùng Edit để ghi địa chỉ __free_hook vào con trỏ fd của chunk 6.
  2. 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.
  3. 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 độ: EasyThể 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}

This post is licensed under CC BY 4.0 by the author.

Trending Tags