PTIT CTF 2026 Quals Writeup
Vòng loại PTIT CTF 2026 mới diễn ra, và rất tình cờ thôi là team mình được vào chung kết với vị trí top 20. Mặc dù khá cay khi mà vì crypto thọt nên từ top 2 bay xuống top 20 :v. Các challenge REV của giải này khá phong phú và có kết hợp cả Web, Forensics và OSINT. Shoutout to @dthangws, trùm Web đã gánh bài Pablo Escobar.
GhostVM
Từ cái đề bài là gợi ý challenge này khả năng cao sẽ obfucate VM rồi.
Phân tích sơ qua DIE trước:

Xem qua thì thấy DIE báo entropy cao => khả năng cao có packer.
Chạy thử chương trình. Đây là chương trình check flag khá cơ bản:

Load vào IDA Pro để phân tích tĩnh. Phát hiện hàm main là sub_140001CD0.
// Hidden C++ exception states: #wind=1
int __fastcall main(int argc, const char **argv, const char **envp)
{
_QWORD *v3; // rax
void *v4; // rcx
__int128 *input_1; // rcx
_QWORD *v6; // rax
__int64 v7; // rax
__int64 v8; // rdx
void *v9; // rcx
int v10; // ebx
_QWORD *v11; // rax
__int64 v12; // rax
__int64 v13; // rdx
void *v14; // rcx
void *input_2; // rcx
_QWORD a1[3]; // [rsp+20h] [rbp-58h] BYREF
unsigned __int64 n0xF; // [rsp+38h] [rbp-40h]
__int128 input; // [rsp+40h] [rbp-38h] BYREF
__int64 v20; // [rsp+50h] [rbp-28h]
unsigned __int64 n15; // [rsp+58h] [rbp-20h]
v3 = sub_140001AB0(a1, (__int64)&a2, 0xCu, 183);
sub_140001000(std::cout, v3);
if ( n0xF > 0xF )
{
v4 = (void *)a1[0];
if ( n0xF + 1 >= 0x1000 )
{
if ( (unsigned __int64)(a1[0] - *(_QWORD *)(a1[0] - 8LL) - 8LL) > 0x1F )
goto LABEL_28;
v4 = *(void **)(a1[0] - 8LL);
}
j_j_free(v4);
}
input = 0;
v20 = 0;
n15 = 15;
LOBYTE(input) = 0;
sub_1400016B0(std::cin, &input);
input_1 = &input;
if ( n15 > 15 )
input_1 = (__int128 *)input;
a1[0] = input_1;
a1[1] = v20;
if ( sub_140005BA0((__int128 *)a1) )
{
v6 = sub_140001AB0(a1, (__int64)&a2_0, 0xDu, 151);
v7 = sub_140001000(std::cout, v6);
LOBYTE(v8) = 10;
sub_140001030(v7, v8);
if ( n0xF <= 15 )
{
LABEL_14:
v10 = 0;
goto LABEL_21;
}
v9 = (void *)a1[0];
if ( n0xF + 1 < 0x1000 )
{
LABEL_13:
j_j_free(v9);
goto LABEL_14;
}
if ( (unsigned __int64)(a1[0] - *(_QWORD *)(a1[0] - 8LL) - 8LL) <= 0x1F )
{
v9 = *(void **)(a1[0] - 8LL);
goto LABEL_13;
}
LABEL_27:
__fastfail(5u);
}
v11 = sub_140001AB0(a1, (__int64)&a2_1, 0xBu, 151);
v12 = sub_140001000(std::cout, v11);
LOBYTE(v13) = 10;
sub_140001030(v12, v13);
if ( n0xF > 0xF )
{
v14 = (void *)a1[0];
if ( n0xF + 1 >= 0x1000 )
{
if ( (unsigned __int64)(a1[0] - *(_QWORD *)(a1[0] - 8LL) - 8LL) > 0x1F )
goto LABEL_27;
v14 = *(void **)(a1[0] - 8LL);
}
j_j_free(v14);
}
v10 = 1;
LABEL_21:
if ( n15 > 0xF )
{
input_2 = (void *)input;
if ( n15 + 1 < 0x1000 )
{
LABEL_25:
j_j_free(input_2);
return v10;
}
if ( (unsigned __int64)(input - *(_QWORD *)(input - 8) - 8) <= 0x1F )
{
input_2 = *(void **)(input - 8);
goto LABEL_25;
}
LABEL_28:
__fastfail(5u);
}
return v10;
}
Nhìn khá rối.
Mình đã thử Shift + F12 để search xem có string nào không, nhưng không có string nào hữu ích cả => binary đã mã hoá string.
Xét dòng 22 và 23, biến v3 được cout ra màn hình, đây là lệnh cout đầu tiên, nên có thể đoán được hàm sub_140001AB0 đang decode chuỗi “Enter flag:” và gán cho biến v3 (muốn chắc hơn thì có thể debug kiểm tra nhưng trong bài này mình không làm vậy).
Đoạn từ dòng 25 -> 35 là destructor cho string, không có gì để phân tích.
Sau đó chương trình bắt đầu đọc input từ người dùng và bắt đầu logic chính. Trong đó hàm sub_140005BA0 chính là hàm check flag. Tiếp theo ta cần phân tích hàm này.
Pseudo code sub_140005BA0:
char __fastcall sub_140005BA0(__int128 *a1)
{
char v3; // bl
_OWORD *v4; // rax
_OWORD *v5; // rcx
__int64 n2; // rdx
void *v7; // r8
void *Block[2]; // [rsp+20h] [rbp-238h] BYREF
__int64 v9; // [rsp+30h] [rbp-228h]
__int128 v10; // [rsp+40h] [rbp-218h] BYREF
_BYTE v11[256]; // [rsp+50h] [rbp-208h] BYREF
_BYTE v12[264]; // [rsp+150h] [rbp-108h] BYREF
if ( *((_QWORD *)a1 + 1) != 32 )
return 0;
*(_OWORD *)Block = 0;
v9 = 0;
if ( (unsigned __int8)sub_1400026A0(Block) )
{
v4 = (_OWORD *)sub_140001F60(v12);
v5 = v11;
n2 = 2;
do
{
*v5 = *v4;
v5[1] = v4[1];
v5[2] = v4[2];
v5[3] = v4[3];
v5[4] = v4[4];
v5[5] = v4[5];
v5[6] = v4[6];
v5[7] = v4[7];
v5 += 8;
v4 += 8;
--n2;
}
while ( n2 );
v10 = *a1;
v3 = sub_1400039B0(Block, &v10, v11);
}
else
{
v3 = 0;
}
if ( !Block[0] )
return v3;
if ( v9 - (unsigned __int64)Block[0] < 0x1000 )
{
j_j_free(Block[0]);
return v3;
}
v7 = (void *)*((_QWORD *)Block[0] - 1);
if ( (unsigned __int64)((char *)Block[0] - (char *)v7 - 8) > 0x1F )
__fastfail(5u);
j_j_free(v7);
return v3;
}
Ok đây chỉ là wrapper của cái hàm check flag thôi chứ chưa phải check flag thật. Cơ mà nhìn dòng 14, 15 mình rút ra được yêu cầu input có độ dài 32 byte.
Phân tích sub_1400026A0:

Nhìn cái graph đoán được ngay hàm sub_1400026A0 là một cái VM. Tuy nhiên hàm sub_1400039B0 được gọi sau đó cũng có vẻ là một VM ???

=> Khả năng cao là bài này là nested VM obfucation. Càng củng cố giả thuyết này hơn khi mà hàm dòng 20 -> 38 đang khởi tạo VM context cho hàm sub_1400039B0.
Khi đã phân biệt được dạng bài VM obfucation và tìm được hàm thực hiện obfucation rồi thì tới đây có thể ném cho LLMs để phân tích 2 cái hàm VM này (có khi nó giải được luôn =)))) ). Vì hàm sub_1400026A0 và sub_1400039B0 rất dài và build lại VM từ 2 hàm này sẽ rất mất thời gian.
Tuy nhiên mình sẽ phân tích 2 hàm này để đúng tinh thần một writeup hơn :v.
Phân tích thuật toán check flag
VM đầu tiên (sub_1400026A0) đang build bytecode để feed cho VM thứ 2 (sub_1400039B0), VM thứ 2 mới là hàm check flag thật sự.
Để có được bytecode thì ta có thể đặt breakpoint khi call sub_7FF75BA126A0

Khi chương trình hỏi input thì nhập 32 ký tự bất kỳ vào.
Sau đó dump Block ra là đã có bytecode để feed cho VM thứ 2.
Sau khi emulate/disassemble VM 2, logic high-level có dạng:
- Input phải dài 32 byte.
- Đọc 32 byte input thành 4 block 64-bit.
- Các block bị tráo thứ tự byte và XOR với hằng số.
- Chạy 16 round biến đổi kiểu Feistel-like.
- So sánh 4 block cuối với 4 constant.
Dạng state:
uint64_t r0, r1, r2, r3;
Mỗi round có dạng:
F = f(r2, r3, round_key);
G = g(r3, r2, round_key);
new_r0 = r2;
new_r1 = r3;
new_r2 = r0 ^ F;
new_r3 = r1 ^ G;
Vì đây là cấu trúc đảo được, ta không cần brute force flag. Chỉ cần lấy final constants ở cuối VM rồi chạy ngược 16 round.
Công thức đảo:
old_r2 = new_r0;
old_r3 = new_r1;
F = f(old_r2, old_r3, round_key);
G = g(old_r3, old_r2, round_key);
old_r0 = new_r2 ^ F;
old_r1 = new_r3 ^ G;
Lặp ngược từ round 15 về round 0 sẽ thu được 4 block ban đầu.
Script solve:
#!/usr/bin/env python3
"""
GhostVM solver / stage2 dumper
Usage:
python solve_ghostvm.py GhostVM.exe
python solve_ghostvm.py GhostVM.exe --dump-stage2 stage2_bytecode.bin
This script rebuilds the 0x1e5c-byte verifier bytecode generated by the
first VM layer (sub_1400026A0), then prints the recovered flag from the
inverted second VM transform.
"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
MASK64 = (1 << 64) - 1
IMAGE_BASE = 0x140000000
# From objdump -h for this binary.
SECTIONS = [
(".text", 0x140001000, 0x5DD8, 0x0400),
(".rdata", 0x140007000, 0x3EFA, 0x6200),
]
GOLDEN = 0x9E3779B97F4A7C15
MIX1 = 0xBF58476D1CE4E5B9
MIX2 = 0x94D049BB133111EB
SEED1 = 0xD00DFEED11223344
OUT_SIZE = 0x1E5C
def u64(x: int) -> int:
return x & MASK64
def rol8(x: int, r: int) -> int:
r &= 7
return ((x << r) | (x >> (8 - r))) & 0xFF if r else x & 0xFF
def rol64(x: int, r: int) -> int:
r &= 63
return ((x << r) | (x >> (64 - r))) & MASK64 if r else x & MASK64
def mix64(x: int) -> int:
x = u64((x ^ (x >> 30)) * MIX1)
x = u64((x ^ (x >> 27)) * MIX2)
return u64(x ^ (x >> 31))
def va_to_off(va: int) -> int:
for _name, vma, size, off in SECTIONS:
if vma <= va < vma + size:
return off + (va - vma)
raise ValueError(f"VA is outside known sections: 0x{va:x}")
def blob_at(exe: bytes, va: int, size: int) -> bytes:
off = va_to_off(va)
return exe[off:off + size]
class EncStream:
"""Encrypted byte stream used by the VM builder."""
def __init__(self, program: bytes, seed: int = SEED1):
self.program = program
self.seed = seed
self.pc = 0
self.eof = False
def key_for_index(self, i: int) -> int:
x = u64(((i & 0xFFFFFFFF) << 32) ^ u64(self.seed + u64(GOLDEN * i)))
x = mix64(x)
return (x ^ (x >> 8) ^ (x >> 24) ^ (x >> 40)) & 0xFF
def read_u8(self) -> int:
if self.pc >= len(self.program):
self.eof = True
return 0
i = self.pc
self.pc += 1
return self.program[i] ^ self.key_for_index(i)
def read_u32(self) -> int:
return sum(self.read_u8() << (8 * i) for i in range(4)) & 0xFFFFFFFF
def read_i32(self) -> int:
x = self.read_u32()
return x - 0x100000000 if x & 0x80000000 else x
def read_u64(self) -> int:
return sum(self.read_u8() << (8 * i) for i in range(8)) & MASK64
def decode_builder_opcode(raw: int, tab_a: bytes, tab_b: bytes) -> int:
a = tab_a[raw]
b = tab_b[raw]
if raw & 7:
b = rol8(b, raw & 7)
return (a ^ b ^ 0xA5) & 0xFF
def build_stage2_bytecode(exe: bytes) -> bytes:
"""Emulate sub_1400026A0 and return the generated 0x1e5c-byte program."""
# Addresses recovered from IDA/Ghidra.
enc_prog = blob_at(exe, 0x1400073A0, 116)
const_table = blob_at(exe, 0x140007420, OUT_SIZE)
tab_a = blob_at(exe, 0x140009280, 256)
tab_b = blob_at(exe, 0x140009380, 256)
out = bytearray(OUT_SIZE)
regs = [0] * 8
s = EncStream(enc_prog, SEED1)
cmp_flag = False
halted = False
runtime_error = False
watchdog = 2_000_000
def reg_index() -> int:
r = s.read_u8()
if r >= 8:
raise RuntimeError(f"bad register index {r} at pc={s.pc}")
return r
while watchdog > 0 and not runtime_error and not s.eof:
watchdog -= 1
raw = s.read_u8()
op = decode_builder_opcode(raw, tab_a, tab_b)
try:
if op == 0x00: # nop
pass
elif op == 0x01: # reg[r] = imm64
r = reg_index()
regs[r] = s.read_u64()
elif op == 0x02: # mov
a, b = reg_index(), reg_index()
regs[a] = regs[b]
elif op == 0x03: # reg[a] = const_table[reg[b]]
a, b = reg_index(), reg_index()
if regs[b] >= len(const_table):
raise RuntimeError("const table OOB")
regs[a] = const_table[regs[b]]
elif op == 0x04: # output[reg[a]] = low8(reg[b])
a, b = reg_index(), reg_index()
if regs[a] >= OUT_SIZE:
raise RuntimeError("output OOB")
out[regs[a]] = regs[b] & 0xFF
elif op == 0x05: # xor qword
a, b = reg_index(), reg_index()
regs[a] = u64(regs[a] ^ regs[b])
elif op == 0x06: # xor low byte
a, b = reg_index(), reg_index()
regs[a] = (regs[a] & ~0xFF) | ((regs[a] ^ regs[b]) & 0xFF)
elif op == 0x07: # add qword
a, b = reg_index(), reg_index()
regs[a] = u64(regs[a] + regs[b])
elif op == 0x08: # add low byte
a, b = reg_index(), reg_index()
regs[a] = u64(regs[a] + (regs[b] & 0xFF))
elif op == 0x09: # add imm64
r = reg_index()
regs[r] = u64(regs[r] + s.read_u64())
elif op == 0x0A: # mul imm64
r = reg_index()
regs[r] = u64(regs[r] * s.read_u64())
elif op == 0x0B: # rol64(reg[a], raw_shift & 0x3f)
a = reg_index()
shift = s.read_u8() & 0x3F
regs[a] = rol64(regs[a], shift)
elif op == 0x0C: # splitmix-style finalizer
a = reg_index()
regs[a] = mix64(regs[a])
elif op == 0x0D: # increment
a = reg_index()
regs[a] = u64(regs[a] + 1)
elif op == 0x0E: # less-than compare
a, b = reg_index(), reg_index()
cmp_flag = regs[a] < regs[b]
elif op == 0x0F: # conditional relative jump
off = s.read_i32()
if cmp_flag:
npc = s.pc + off
if npc < 0 or npc > len(s.program):
raise RuntimeError("bad jcc target")
s.pc = npc
elif op == 0x10: # unconditional relative jump
off = s.read_i32()
npc = s.pc + off
if npc < 0 or npc > len(s.program):
raise RuntimeError("bad jmp target")
s.pc = npc
elif op == 0x11: # halt
halted = True
break
else:
raise RuntimeError(f"bad opcode 0x{op:02x}")
except RuntimeError as e:
runtime_error = True
raise
if not halted or runtime_error or s.eof or watchdog == 0:
raise RuntimeError("VM builder did not halt cleanly")
return bytes(out)
def recover_flag_from_vm2_analysis() -> bytes:
"""
Result of inverting the second VM verifier.
In the write-up: VM2 groups the 32-byte input into four little-endian
qwords, applies reversible Feistel-like rounds, then compares against
constants. Inverting those rounds gives these four qwords.
"""
recovered_qwords = [
0x7B46544354495450,
0x756A5F73315F6D76,
0x3468735F345F7435,
0x7D2133725F773064,
]
return b"".join(struct.pack("<Q", x) for x in recovered_qwords)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("exe", nargs="?", default="GhostVM.exe")
ap.add_argument("--dump-stage2", metavar="PATH", help="write generated VM2 bytecode")
args = ap.parse_args()
exe = Path(args.exe).read_bytes()
stage2 = build_stage2_bytecode(exe)
print(f"[+] stage2 bytecode size: 0x{len(stage2):x}")
print(f"[+] stage2 first 16 bytes: {stage2[:16].hex()}")
if args.dump_stage2:
Path(args.dump_stage2).write_bytes(stage2)
print(f"[+] wrote {args.dump_stage2}")
flag = recover_flag_from_vm2_analysis()
assert len(flag) == 32
assert flag.startswith(b"PTITCTF{") and flag.endswith(b"}")
print(f"[+] flag: {flag.decode()}")
if __name__ == "__main__":
main()
Flag: PTITCTF{vm_1s_ju5t_4_sh4d0w_r3!}
Winux
Challenge cho ta file Winux.zip, bên trong gồm:
winux.exe
rootfs.wux
lib/winuxsvc.dll
lib/libpam_w32.dll
Triage qua DIE thì biết chương trình winux.exe viết bằng C++, nhưng không có gì đặc biệt.

Chạy thử chương trình:

Ok ngon vậy là ta đã có được flag.
Flag: PTITCTF{50m371m35_my_c011346u35_pr3f3r_u51n6_11nux_0v3r_w1nd0w5}
Có cái nịt, fake flag nhé ae =)))))).
Bật IDA Pro phân tích tĩnh winnux.exe. Không thấy có gì thú vị, nhưng mà ở hàm main mình thấy nó đang load library trong folder lib vào.

Hướng tiếp theo là phân tích xem thư viện winuxsvc.dll và libpam_w32.dll có gì.
Phân tích tĩnh winuxsvc.dll.
Phân tích tingx winuxsvc.dll trước. Có khá nhiều strings thú vị trong này:

Check xem dll này đang export những hàm nào:

Ok vậy là hàm WnxService là hàm duy nhất cần quan tâm (3 hàm còn lại là export mặc định, với Tls thường để check debug và DllEntryPoint là điểm bắt đầu của file dll).
Nhìn sơ qua thì hàm này đang mở rootfs.wux, check magic number:

0x32585557 là little-endian của WUX2
Sau đó chương trình parse các entry trong rootfs.wux, rồi xử lý các request tương ứng với path như /root/flag.txt, /root/flag, /etc/os-release, /etc/sudoers, v.v.
Thứ ta cần quan tâm là case 4. Đây là khu vực check flag:

case 4:
v24 = *(_QWORD *)&v75[0];
if ( strcmp((const char *)(*(_QWORD *)&v75[0] + 292LL), "/root/flag.txt")
&& strcmp((const char *)(v24 + 292), "/root/flag") )
{
sub_3665F5CB0((char *)(*((_QWORD *)&v75[0] + 1) + 24LL), 0x800u, "grep: %s: No such file or directory\n");
return (unsigned int)a2[1];
}
if ( *(_DWORD *)(v24 + 16) == 4 && (v30 = sub_3665F1570("real", 0, v24), v24 = *(_QWORD *)&v75[0], v30)
|| *(_DWORD *)(v24 + 8)
|| !*(_DWORD *)(v24 + 12) )
{
strcpy(winux:char:v3, "winux:char:v3");
v25 = strlen((const char *)(v24 + 866)) == 64;
v26 = 0;
if ( v25 )
{
n64 = 0;
v72 = &v64;
v28 = (char *)&unk_3665F7B40;
Str2_ = _mm_unpacklo_epi64((__m128i)(unsigned __int64)winux:char:v3, (__m128i)(unsigned __int64)&n64_1);
*(__m128i *)Str2 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8060);
v74 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8070);
do
{
n64_1 = n64;
v66 = 0;
v67 = 0;
v29 = byte_3665F7F40[n64] ^ *(_BYTE *)(v24 + n64 + 866);
v71 = v28;
v64 = v29;
sub_3665F48A0(Str2_1, &Str2_, Str2, 4);
v26 = sub_3665F4B90(Str2_1, (char *)&unk_3665F7340 + 32 * n64, 32);
if ( !v26 )
{
v24 = *(_QWORD *)&v75[0];
goto LABEL_46;
}
++n64;
v28 += 16;
}
while ( n64 != 64 );
v24 = *(_QWORD *)&v75[0];
v26 = 1;
}
LABEL_46:
Str2_.m128i_i32[0] = v26;
*(_DWORD *)Str2 = sub_3665F3EF0(v26, *(unsigned int *)(v24 + 1202), *(unsigned int *)(v24 + 24));
goto LABEL_47;
}
v44 = *((_QWORD *)&v75[0] + 1);
Permission_denied__Try_running_as_Administrator_n_2 = "Permission denied! Try running as Administrator\n";
for ( j = 0; j != 2047; *(_BYTE *)(v44 + j++ + 24) = *Permission_denied__Try_running_as_Administrator_n_2++ )
{
if ( !*Permission_denied__Try_running_as_Administrator_n_2 )
break;
}
goto LABEL_77;
Đầu tiên, case 4 kiểm tra path đang grep là /root/flag.txt hoặc /root/flag. Đoạn check flag/hash 64 ký tự bắt đầu rõ nhất ở đây:
strcpy(winux:char:v3, "winux:char:v3");
v25 = strlen((const char *)(v24 + 866)) == 64;
Tại đây nó đang lấy chuỗi input ở v24 + 866 rồi yêu cầu độ dài là đúng 64.
Sau đó chương trình chạy vòng lặp check ký tự:
strcpy(winux:char:v3, "winux:char:v3");
v25 = strlen((const char *)(v24 + 866)) == 64;
v26 = 0;
if ( v25 )
{
n64 = 0;
v72 = &v64;
v28 = (char *)&unk_3665F7B40;
Str2_ = _mm_unpacklo_epi64((__m128i)(unsigned __int64)winux:char:v3, (__m128i)(unsigned __int64)&n64_1);
*(__m128i *)Str2 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8060);
v74 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8070);
do
{
n64_1 = n64;
v66 = 0;
v67 = 0;
v29 = byte_3665F7F40[n64] ^ *(_BYTE *)(v24 + n64 + 866);
v71 = v28;
v64 = v29;
sub_3665F48A0(Str2_1, &Str2_, Str2, 4);
v26 = sub_3665F4B90(Str2_1, (char *)&unk_3665F7340 + 32 * n64, 32);
if ( !v26 )
{
v24 = *(_QWORD *)&v75[0];
goto LABEL_46;
}
++n64;
v28 += 16;
}
while ( n64 != 64 );
v24 = *(_QWORD *)&v75[0];
v26 = 1;
}
Tại đây chương trình check từng byte của input bằng cách XOR với byte_3665F7F40[n64], hash bằng sub_3665F48A0, rồi so sánh digest với bảng hash tại unk_3665F7340 + 32 * n64.
Bởi vì chương trình check từng byte một cách độc lập => có thể brute force từng byte một.
Các vùng dữ liệu dùng cho check:
expected hash table : unk_3665F7340 (RVA 0x7340), size 64 * 32
salt table : unk_3665F7B40 (RVA 0x7B40), size 64 * 16
xor mask : byte_3665F7F40 (RVA 0x7F40), size 64
Script solve:
#!/usr/bin/env python3
import hashlib
import struct
import sys
import zipfile
from pathlib import Path
EXPECTED_RVA = 0x7340 # unk_3665F7340, 64 * 32 bytes
SALT_RVA = 0x7B40 # unk_3665F7B40, 64 * 16 bytes
MASK_RVA = 0x7F40 # byte_3665F7F40, 64 bytes
PREFIX = b"winux:char:v3"
def load_dll(path: str) -> bytes:
p = Path(path)
if p.suffix.lower() == ".zip":
with zipfile.ZipFile(p, "r") as z:
# Trong challenge, DLL nằm ở lib/winuxsvc.dll
return z.read("lib/winuxsvc.dll")
return p.read_bytes()
def parse_sections(pe: bytes):
if pe[:2] != b"MZ":
raise ValueError("Not a PE file")
pe_off = struct.unpack_from("<I", pe, 0x3C)[0]
if pe[pe_off:pe_off + 4] != b"PE\x00\x00":
raise ValueError("Invalid PE signature")
num_sections = struct.unpack_from("<H", pe, pe_off + 6)[0]
opt_header_size = struct.unpack_from("<H", pe, pe_off + 20)[0]
section_table = pe_off + 24 + opt_header_size
sections = []
for i in range(num_sections):
off = section_table + i * 40
name = pe[off:off + 8].rstrip(b"\x00").decode(errors="ignore")
virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from(
"<IIII",
pe,
off + 8
)
sections.append({
"name": name,
"va": virtual_address,
"vsize": virtual_size,
"raw_size": raw_size,
"raw_ptr": raw_ptr,
})
return sections
def rva_to_offset(rva: int, sections) -> int:
for s in sections:
start = s["va"]
size = max(s["vsize"], s["raw_size"])
end = start + size
if start <= rva < end:
return s["raw_ptr"] + (rva - start)
raise ValueError(f"Cannot convert RVA {hex(rva)} to file offset")
def recover_flag(pe: bytes) -> bytes:
sections = parse_sections(pe)
expected_off = rva_to_offset(EXPECTED_RVA, sections)
salt_off = rva_to_offset(SALT_RVA, sections)
mask_off = rva_to_offset(MASK_RVA, sections)
expected = pe[expected_off:expected_off + 64 * 32]
salts = pe[salt_off:salt_off + 64 * 16]
mask = pe[mask_off:mask_off + 64]
flag = bytearray()
for i in range(64):
target_digest = expected[i * 32:(i + 1) * 32]
salt = salts[i * 16:(i + 1) * 16]
found = None
for c in range(256):
msg = (
PREFIX
+ struct.pack("<I", i)
+ salt
+ bytes([c ^ mask[i]])
)
digest = hashlib.sha256(msg).digest()
if digest == target_digest:
found = c
break
if found is None:
raise RuntimeError(f"Cannot recover byte at index {i}")
flag.append(found)
return bytes(flag)
def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} Winux.zip")
print(f" or: {sys.argv[0]} lib/winuxsvc.dll")
sys.exit(1)
pe = load_dll(sys.argv[1])
flag = recover_flag(pe)
print(flag.decode(errors="replace"))
if __name__ == "__main__":
main()

Flag: PTITCTF{1_7h1nk_y0u_kn0w_h0w_70_cr4ck_p1r473d_50f7w4r3_R19H7???}
Process
Challenge này yêu cầu mở instance. Sau đó ta sẽ tải file challenge từ instance này. Challenge cung cấp một file chall.exe.
Triage cơ bản với DIE.

Ở đây DIE báo có packer, có vẻ section .rdata có packed code.
Chạy thử chương trình:

Ok ở đây ta có một cái flag (tất nhiên là flag đểu :v).
Load vào IDA Pro để phân tích tĩnh. Tìm tới hàm main:
int __fastcall main(int argc, const char **argv, const char **envp)
{
_BYTE proc_name[48]; // [rsp+30h] [rbp-90h] BYREF
_QWORD v5[11]; // [rsp+60h] [rbp-60h] BYREF
unsigned int pid; // [rsp+B8h] [rbp-8h]
int n1613; // [rsp+BCh] [rbp-4h]
sub_140002730();
n1613 = 1613;
if ( (unsigned __int8)check_debug() )
{
print_fake_flag();
return 0;
}
else if ( sub_14000183F(v5) )
{
create_packed_proc_name((__int64)proc_name);
pid = find_pid((__int64)v5, (__int64)proc_name);
if ( pid )
{
if ( (unsigned int)inject_3_bytes_and_run((__int64)v5, pid) )
puts("The sealed page changed somewhere else.");
else
print_fake_flag();
return 0;
}
else
{
print_fake_flag();
return 0;
}
}
else
{
print_fake_flag();
return 0;
}
}
Mình phân tích để xem xem các hàm trong main đang thực hiện việc gì. Sau đó rename lại các hàm và được kết quả như trên. Vì các hàm cũng khá ngắn và dễ hiểu nên mình sẽ không giải thích bước này.
Tóm tắt thì chương trình xor dữ liệu với 0x97 để tạo một string "1dRxp.exe" là tên của một process. Khi có một process với tên như vậy đang chạy, chương trình sẽ không in decoy flag nữa mà sẽ in ra The sealed page changed somewhere else..
Nhớ lại lúc đầu khi triage DIE có phát hiện packed data ở section .rdata.

Thấy cái header lạ lạ. Tuy nhiên tra cứu thì không thấy kết quả gì cả. Có vẻ đây là một header custom.
Dump section này ra và cho LLMs phân tích. Có thể rút ra được thông tin sau từ section này:
total length: 0x64d
encrypted payload length: 0x577
fake XOR key: 0x28 (cái này sinh ra decoy flag lúc đầu)
trailer field lengths: 0x16, 0x9, 0x11, 0x4, 0x4, 0x4, 0x4
stream key: 8f69ab60f395305b72f90504b98eaf38
salt: 1drxp
Ok vậy hướng đi bây giờ là cần tìm xem trong binary kia chỗ nào đang chịu trách nhiệm cho việc decrypt section .rdata. Sau khi mò một hồi thì mình thấy hàm khá sus:
__int64 __fastcall sub_140002379(__int64 a1, unsigned int i, int a3)
{
__int64 j_1; // rax
unsigned int v4; // [rsp+38h] [rbp-C8h] BYREF
unsigned int v5; // [rsp+3Ch] [rbp-C4h] BYREF
int v6[6]; // [rsp+40h] [rbp-C0h] BYREF
_BYTE p_a2_1[4]; // [rsp+58h] [rbp-A8h] BYREF
int a2; // [rsp+5Ch] [rbp-A4h] BYREF
__int64 a4; // [rsp+60h] [rbp-A0h] BYREF
unsigned __int16 v10[2]; // [rsp+68h] [rbp-98h] BYREF
unsigned __int16 v11; // [rsp+6Ch] [rbp-94h] BYREF
unsigned __int16 v12[2]; // [rsp+70h] [rbp-90h] BYREF
unsigned __int16 v13[6]; // [rsp+74h] [rbp-8Ch] BYREF
__int64 p_a2[13]; // [rsp+80h] [rbp-80h] BYREF
int v15; // [rsp+ECh] [rbp-14h]
int v16; // [rsp+F0h] [rbp-10h]
unsigned int i_1; // [rsp+F4h] [rbp-Ch]
unsigned int k; // [rsp+F8h] [rbp-8h]
unsigned int j; // [rsp+FCh] [rbp-4h]
i_1 = sub_1400021C2((__int64)p_a2, 0x60u);
sub_140002073((__int64)p_a2, i_1, a3, (__int64)&a4, 0x18u);
v6[0] = sub_140001F02((unsigned __int16 *)&a4);
v6[1] = sub_140001F02((unsigned __int16 *)&a4 + 2);
v6[2] = sub_140001F02(v10);
v6[3] = sub_140001F02(&v11);
v16 = sub_140001F02(v12);
v15 = sub_140001F02(v13);
for ( j = 0; ; j += 8 )
{
j_1 = j;
if ( j >= i )
break;
v5 = v16 ^ (j >> 3);
v4 = a3 ^ v15;
sub_140002273(&v5, (int *)&v4, v6);
sub_140001F4F((__int64)p_a2_1, v5);
sub_140001F4F((__int64)&a2, v4);
for ( k = 0; k <= 7 && j + k < i; ++k )
*(_BYTE *)(j + k + a1) ^= p_a2_1[k];
}
return j_1;
}
Trace sâu hơn vào các hàm con của hàm này. Và ta thấy nó đang gọi một hằng số khá sus:

Đây là magic byte của hàm hash Fowler–Noll–Vo (FNV).
Tới đây thì có thể quăng cho LLMs decrypt theo hàm này được rồi.
Thuật toán mã hóa trong sub_140002379 sinh keystream bằng cách dùng:
seed FNV-1a 64-bit: 0xcbf29ce484222325
config key 16 byte
nonce field 32-bit
SplitMix64
mixer 32-bit chạy 27 vòng
Phần trailer cuối dài 64 byte được chia theo các độ dài field ở trên, sau đó từng field được giải mã độc lập với nonce từ 0..6. Sau khi nối các field lại, ta thu được:
50m371m35_1nf0rm4710n_1n_420e3611bb577_pr0c355_15_v3ry_1mp0r74n7
Script solve:
import re
import struct
import sys
from pathlib import Path
FLAG_RE = re.compile(rb"PTITCTF\{[ -~]+\}")
def rol32(value: int, count: int) -> int:
value &= 0xFFFFFFFF
return ((value << count) & 0xFFFFFFFF) | (value >> (32 - count))
def ror32(value: int, count: int) -> int:
value &= 0xFFFFFFFF
return (value >> count) | ((value << (32 - count)) & 0xFFFFFFFF)
def read_u32(data: bytes) -> int:
return struct.unpack("<I", data)[0]
def write_u32(value: int) -> bytes:
return struct.pack("<I", value & 0xFFFFFFFF)
def fnv64(state: int, data: bytes) -> int:
for byte in data:
state = (0x100000001B3 * (byte ^ state)) & 0xFFFFFFFFFFFFFFFF
return state
def splitmix_next(state: int) -> tuple[int, int]:
state = (state - 0x61C8864680B583EB) & 0xFFFFFFFFFFFFFFFF
mixed = (0xBF58476D1CE4E5B9 * (state ^ (state >> 30))) & 0xFFFFFFFFFFFFFFFF
mixed = (0x94D049BB133111EB * (mixed ^ (mixed >> 27))) & 0xFFFFFFFFFFFFFFFF
return state, (mixed ^ (mixed >> 31)) & 0xFFFFFFFFFFFFFFFF
def stream_seed(salt: bytes, key: bytes, nonce: int, size: int) -> bytes:
state = fnv64(0xCBF29CE484222325, salt)
state = fnv64(state, key)
state = fnv64(state, write_u32(nonce))
out = bytearray()
while len(out) < size:
state, value = splitmix_next(state)
out.extend(struct.pack("<Q", value))
return bytes(out[:size])
def mix_round(left: int, right: int, key_words: list[int]) -> tuple[int, int]:
rolling = key_words[0]
work = key_words[1:4]
for index in range(27):
old = rolling
left = (old ^ ((right + ror32(left, 8)) & 0xFFFFFFFF)) & 0xFFFFFFFF
right = (rol32(right, 3) ^ left) & 0xFFFFFFFF
slot = index % 3
work[slot] = (index ^ ((old + ror32(work[slot], 8)) & 0xFFFFFFFF)) & 0xFFFFFFFF
rolling = (work[slot] ^ rol32(old, 3)) & 0xFFFFFFFF
return left, right
def decrypt_field(data: bytes, salt: bytes, key: bytes, nonce: int) -> bytes:
seed = stream_seed(salt, key, nonce, 24)
key_words = [read_u32(seed[offset:offset + 4]) for offset in range(0, 16, 4)]
base_left = read_u32(seed[16:20])
base_right = read_u32(seed[20:24])
out = bytearray(data)
for offset in range(0, len(out), 8):
left = base_left ^ (offset >> 3)
right = nonce ^ base_right
left, right = mix_round(left, right, key_words)
stream = write_u32(left) + write_u32(right)
for index in range(min(8, len(out) - offset)):
out[offset + index] ^= stream[index]
return bytes(out)
def extract_flag(binary_path: Path) -> bytes:
blob = binary_path.read_bytes()
config_offset = blob.index(b"PESCFGv3M")
values = list(struct.unpack_from("<15I", blob, config_offset + 8))
total_len = values[0]
process_len = values[3]
decoy_len = values[5]
encrypted_payload_len = values[6]
key_len = values[4]
key_offset = config_offset + 8 + 15 * 4
process_offset = key_offset + key_len
decoy_offset = process_offset + process_len
trailer_offset = decoy_offset + decoy_len + encrypted_payload_len
key = blob[key_offset:process_offset]
process_name = blob[process_offset:decoy_offset]
salt = process_name.split(b".e")[0].lower()
field_lengths = values[8:11] + values[11:15]
if sum(field_lengths) != total_len - (trailer_offset - config_offset):
raise RuntimeError("Unexpected PESCFG trailer layout")
flag_body = bytearray()
cursor = trailer_offset
for nonce, field_len in enumerate(field_lengths):
encrypted_field = blob[cursor:cursor + field_len]
flag_body.extend(decrypt_field(encrypted_field, salt, key, nonce))
cursor += field_len
flag = b"PTITCTF{" + bytes(flag_body) + b"}"
if not FLAG_RE.fullmatch(flag):
raise RuntimeError(f"Decoded bytes do not look like a flag: {flag!r}")
return flag
def main() -> int:
binary_path = Path(__file__).with_name("chall.exe")
print(extract_flag(binary_path).decode())
return 0
if __name__ == "__main__":
sys.exit(main())
Flag: PTITCTF{50m371m35_1nf0rm4710n_1n_420e3611bb577_pr0c355_15_v3ry_1mp0r74n7}.
Pablo
Bài này có 2 phase, phase đầu tiên là REV do mình làm. Phase sau là web, do @dthangws làm. Thật ra bài này phase REV khá dễ.
Challenge cung cấp 2 file là Tool.exe và config.enc.
Xem qua file config.enc:
{
"version": 1,
"kdf": "pbkdf2-sha256",
"iterations": 210000,
"salt": "91fS3SAVqIPnMb8UEB0GWA",
"nonce": "5Oql/E6+RenWpfIQ",
"ciphertext": "xSPz+ilj6AV+cHnhMOjfHw3d/HW01qx3Ge+d6SoXh4cUL3BiI0ba3QpK2hUSu0FMnsar79NbH+kgeBKvy5TF8dc0EvzuNAcCqHeWrnwZ2q5+dkPp/iMZPWvgfbABpXea4VwObA/MRTf9d7R12NY7f/3glTgHthaWO9Ca8Poqg+A9Kh11rM2o9z5iBO6s0IaImahsJqCNYhxHYcPogdEQSKP73tJZqWetRRW/SsuX9ZTta+rV6WY8KOjDKmGWlHgxjFNug9d6Y+oG85qpJklTCr3EF9ZOHWxmtpC8wn3vwEQPbkrCRC6mgbAxGw"
}
Như vậy file này chứa thông tin mã hoá, gồm thuật toán mã hoá là pbkdf2-sha256 và đầy đủ salt, nonce, ciphertext. Nhiệm vụ bây giờ là tìm key để decrypt thôi.
DIE báo binary viết bằng go:

Load luôn vào IDA Pro:


Như vậy là binary còn nguyên hàm configKey thực hiện việc sinh key.
// configurable-webview/internal/configkey.Key
__int64 configurable_webview_internal_configkey_Key()
{
__int64 result; // rax
__int64 i; // rcx
result = runtime_makeslice(&RTYPE_uint8, 44, 44);
for ( i = 0; i < 44; ++i )
*(_BYTE *)(result + i) = *((_BYTE *)&xmmword_140297C80 + i) ^ *((_BYTE *)&xmmword_140297CC0 + i) ^ (73 * i + 93);
return result;
}
Hàm này khá đơn giản. Để giải mã thì dump xmmword_140297C80 và xmmword_140297CC0 rồi XOR ngược lại là xong :v.
Decrypt key:
key = bytes(
obf_a[i] ^ obf_b[i] ^ ((73 * i + 0x5d) & 0xff)
for i in range(0x2c)
)
print(key.decode())
Dùng key để decrypt config.enc, ta được kết quả sau:
{
"url": "http://144.79.188.36:6500/",
"title": "WebView Tool",
"width": 1280,
"height": 800,
"min_width": 640,
"min_height": 480,
"resizable": true,
"center": true,
"debug": false,
"data_dir": ""
}
Truy cập http://144.79.188.36:6500/ với tài khoàn mật khẩu là luongvd.
Khúc sau mọi người đọc writeup của @dthangws nha.
Đoạn này là tìm được account của admin tk: admin, mk: Admin@1234.

Tiger Bạc
Phase đầu mình và teammate @minhincs OSINT ra được tiktok này: https://www.tiktok.com/@luonginfosec/photo/7673158715662519569

Để ý cái ảnh này trong post tiktok.
Sau đó dùng sherlock + maigret, minhincs OSINT ra được repo này, giải nén file zip bằng mật khâu là luongvd1234! suy ra từ cái post tiktok ở trên.
https://gitlab.com/luonginfosec/checker
Tới đây mình nghĩ là bài này ngon ăn, nhưng mà thế nào làm toàn ra decoy flag, đặc biệt là còn ra cả một file README.txt nội dung thế này.
Congratulations. This archive is an intentionally convincing dead end.
Sau đó mình nghĩ là cả cái repo là decoy ngay từ đầu nên không thử đào sâu hơn nữa, rồi quay ra OSINT tiếp =)))). Nhưng mà có vẻ cái repo này ra flag real.
Khá tiếc khi mình không ra được flag bài này, đây là các decoy flag mình làm ra:
PTITCTF{th3_n1nth_g4t3_0pens_4t_09_17}
PTITCTF{d3bug_m0d3_always_tells_the_truth}
PTITCTF{the_metadata_was_the_flag}
PTITCTF{binwalk_found_the_real_secret}
PTITCTF{ai_solved_this_in_one_prompt}
PTITCTF{strings_is_all_you_need}
PTITCTF{just_patch_the_final_jump}
PTITCTF{trust_the_blue_channel}
PTITCTF{nine_is_a_red_herring}
PTITCTF{the_monolith_is_watching_you}
PTITCTF{crc32_is_cryptography}
Vậy là mình kết thúc với 6/7 bài REV. Các challenge mùa giải áp dụng nhiều mảng với nhau. Lời cuối xin phép cảm ơn ban tổ chức vì những challenge rất thú vị!
