PTIT CTF 2026 Quals Writeup

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

GhostVM

Từ cái đề bài là gợi ý challenge này khả năng cao sẽ obfucate VM rồi.

Phân tích sơ qua DIE trước:

image

Xem qua thì thấy DIE báo entropy cao => khả năng cao có packer.

Chạy thử chương trình. Đây là chương trình check flag khá cơ bản:

image

Load vào IDA Pro để phân tích tĩnh. Phát hiện hàm main là sub_140001CD0.

// Hidden C++ exception states: #wind=1
int __fastcall main(int argc, const char **argv, const char **envp)
{
  _QWORD *v3; // rax
  void *v4; // rcx
  __int128 *input_1; // rcx
  _QWORD *v6; // rax
  __int64 v7; // rax
  __int64 v8; // rdx
  void *v9; // rcx
  int v10; // ebx
  _QWORD *v11; // rax
  __int64 v12; // rax
  __int64 v13; // rdx
  void *v14; // rcx
  void *input_2; // rcx
  _QWORD a1[3]; // [rsp+20h] [rbp-58h] BYREF
  unsigned __int64 n0xF; // [rsp+38h] [rbp-40h]
  __int128 input; // [rsp+40h] [rbp-38h] BYREF
  __int64 v20; // [rsp+50h] [rbp-28h]
  unsigned __int64 n15; // [rsp+58h] [rbp-20h]

  v3 = sub_140001AB0(a1, (__int64)&a2, 0xCu, 183);
  sub_140001000(std::cout, v3);
  if ( n0xF > 0xF )
  {
    v4 = (void *)a1[0];
    if ( n0xF + 1 >= 0x1000 )
    {
      if ( (unsigned __int64)(a1[0] - *(_QWORD *)(a1[0] - 8LL) - 8LL) > 0x1F )
        goto LABEL_28;
      v4 = *(void **)(a1[0] - 8LL);
    }
    j_j_free(v4);
  }
  input = 0;
  v20 = 0;
  n15 = 15;
  LOBYTE(input) = 0;
  sub_1400016B0(std::cin, &input);
  input_1 = &input;
  if ( n15 > 15 )
    input_1 = (__int128 *)input;
  a1[0] = input_1;
  a1[1] = v20;
  if ( sub_140005BA0((__int128 *)a1) )
  {
    v6 = sub_140001AB0(a1, (__int64)&a2_0, 0xDu, 151);
    v7 = sub_140001000(std::cout, v6);
    LOBYTE(v8) = 10;
    sub_140001030(v7, v8);
    if ( n0xF <= 15 )
    {
LABEL_14:
      v10 = 0;
      goto LABEL_21;
    }
    v9 = (void *)a1[0];
    if ( n0xF + 1 < 0x1000 )
    {
LABEL_13:
      j_j_free(v9);
      goto LABEL_14;
    }
    if ( (unsigned __int64)(a1[0] - *(_QWORD *)(a1[0] - 8LL) - 8LL) <= 0x1F )
    {
      v9 = *(void **)(a1[0] - 8LL);
      goto LABEL_13;
    }
LABEL_27:
    __fastfail(5u);
  }
  v11 = sub_140001AB0(a1, (__int64)&a2_1, 0xBu, 151);
  v12 = sub_140001000(std::cout, v11);
  LOBYTE(v13) = 10;
  sub_140001030(v12, v13);
  if ( n0xF > 0xF )
  {
    v14 = (void *)a1[0];
    if ( n0xF + 1 >= 0x1000 )
    {
      if ( (unsigned __int64)(a1[0] - *(_QWORD *)(a1[0] - 8LL) - 8LL) > 0x1F )
        goto LABEL_27;
      v14 = *(void **)(a1[0] - 8LL);
    }
    j_j_free(v14);
  }
  v10 = 1;
LABEL_21:
  if ( n15 > 0xF )
  {
    input_2 = (void *)input;
    if ( n15 + 1 < 0x1000 )
    {
LABEL_25:
      j_j_free(input_2);
      return v10;
    }
    if ( (unsigned __int64)(input - *(_QWORD *)(input - 8) - 8) <= 0x1F )
    {
      input_2 = *(void **)(input - 8);
      goto LABEL_25;
    }
LABEL_28:
    __fastfail(5u);
  }
  return v10;
}

Nhìn khá rối.

Mình đã thử Shift + F12 để search xem có string nào không, nhưng không có string nào hữu ích cả => binary đã mã hoá string.

Xét dòng 22 và 23, biến v3 được cout ra màn hình, đây là lệnh cout đầu tiên, nên có thể đoán được hàm sub_140001AB0 đang decode chuỗi “Enter flag:” và gán cho biến v3 (muốn chắc hơn thì có thể debug kiểm tra nhưng trong bài này mình không làm vậy).

Đoạn từ dòng 25 -> 35 là destructor cho string, không có gì để phân tích.

Sau đó chương trình bắt đầu đọc input từ người dùng và bắt đầu logic chính. Trong đó hàm sub_140005BA0 chính là hàm check flag. Tiếp theo ta cần phân tích hàm này.

Pseudo code sub_140005BA0:

char __fastcall sub_140005BA0(__int128 *a1)
{
  char v3; // bl
  _OWORD *v4; // rax
  _OWORD *v5; // rcx
  __int64 n2; // rdx
  void *v7; // r8
  void *Block[2]; // [rsp+20h] [rbp-238h] BYREF
  __int64 v9; // [rsp+30h] [rbp-228h]
  __int128 v10; // [rsp+40h] [rbp-218h] BYREF
  _BYTE v11[256]; // [rsp+50h] [rbp-208h] BYREF
  _BYTE v12[264]; // [rsp+150h] [rbp-108h] BYREF

  if ( *((_QWORD *)a1 + 1) != 32 )
    return 0;
  *(_OWORD *)Block = 0;
  v9 = 0;
  if ( (unsigned __int8)sub_1400026A0(Block) )
  {
    v4 = (_OWORD *)sub_140001F60(v12);
    v5 = v11;
    n2 = 2;
    do
    {
      *v5 = *v4;
      v5[1] = v4[1];
      v5[2] = v4[2];
      v5[3] = v4[3];
      v5[4] = v4[4];
      v5[5] = v4[5];
      v5[6] = v4[6];
      v5[7] = v4[7];
      v5 += 8;
      v4 += 8;
      --n2;
    }
    while ( n2 );
    v10 = *a1;
    v3 = sub_1400039B0(Block, &v10, v11);
  }
  else
  {
    v3 = 0;
  }
  if ( !Block[0] )
    return v3;
  if ( v9 - (unsigned __int64)Block[0] < 0x1000 )
  {
    j_j_free(Block[0]);
    return v3;
  }
  v7 = (void *)*((_QWORD *)Block[0] - 1);
  if ( (unsigned __int64)((char *)Block[0] - (char *)v7 - 8) > 0x1F )
    __fastfail(5u);
  j_j_free(v7);
  return v3;
}

Ok đây chỉ là wrapper của cái hàm check flag thôi chứ chưa phải check flag thật. Cơ mà nhìn dòng 14, 15 mình rút ra được yêu cầu input có độ dài 32 byte.

Phân tích sub_1400026A0:

image

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

image

=> Khả năng cao là bài này là nested VM obfucation. Càng củng cố giả thuyết này hơn khi mà hàm dòng 20 -> 38 đang khởi tạo VM context cho hàm sub_1400039B0.

Khi đã phân biệt được dạng bài VM obfucation và tìm được hàm thực hiện obfucation rồi thì tới đây có thể ném cho LLMs để phân tích 2 cái hàm VM này (có khi nó giải được luôn =)))) ). Vì hàm sub_1400026A0 và sub_1400039B0 rất dài và build lại VM từ 2 hàm này sẽ rất mất thời gian.

Tuy nhiên mình sẽ phân tích 2 hàm này để đúng tinh thần một writeup hơn :v.

Phân tích thuật toán check flag

VM đầu tiên (sub_1400026A0) đang build bytecode để feed cho VM thứ 2 (sub_1400039B0), VM thứ 2 mới là hàm check flag thật sự.

Để có được bytecode thì ta có thể đặt breakpoint khi call sub_7FF75BA126A0

image

Khi chương trình hỏi input thì nhập 32 ký tự bất kỳ vào.

Sau đó dump Block ra là đã có bytecode để feed cho VM thứ 2.

Sau khi emulate/disassemble VM 2, logic high-level có dạng:

  1. Input phải dài 32 byte.
  2. Đọc 32 byte input thành 4 block 64-bit.
  3. Các block bị tráo thứ tự byte và XOR với hằng số.
  4. Chạy 16 round biến đổi kiểu Feistel-like.
  5. So sánh 4 block cuối với 4 constant.

Dạng state:

uint64_t r0, r1, r2, r3;

Mỗi round có dạng:

F = f(r2, r3, round_key);
G = g(r3, r2, round_key);

new_r0 = r2;
new_r1 = r3;
new_r2 = r0 ^ F;
new_r3 = r1 ^ G;

Vì đây là cấu trúc đảo được, ta không cần brute force flag. Chỉ cần lấy final constants ở cuối VM rồi chạy ngược 16 round.

Công thức đảo:

old_r2 = new_r0;
old_r3 = new_r1;

F = f(old_r2, old_r3, round_key);
G = g(old_r3, old_r2, round_key);

old_r0 = new_r2 ^ F;
old_r1 = new_r3 ^ G;

Lặp ngược từ round 15 về round 0 sẽ thu được 4 block ban đầu.

Script solve:

#!/usr/bin/env python3
"""
GhostVM solver / stage2 dumper

Usage:
    python solve_ghostvm.py GhostVM.exe
    python solve_ghostvm.py GhostVM.exe --dump-stage2 stage2_bytecode.bin

This script rebuilds the 0x1e5c-byte verifier bytecode generated by the
first VM layer (sub_1400026A0), then prints the recovered flag from the
inverted second VM transform.
"""
from __future__ import annotations

import argparse
import struct
from pathlib import Path

MASK64 = (1 << 64) - 1
IMAGE_BASE = 0x140000000

# From objdump -h for this binary.
SECTIONS = [
    (".text",  0x140001000, 0x5DD8, 0x0400),
    (".rdata", 0x140007000, 0x3EFA, 0x6200),
]

GOLDEN = 0x9E3779B97F4A7C15
MIX1   = 0xBF58476D1CE4E5B9
MIX2   = 0x94D049BB133111EB
SEED1  = 0xD00DFEED11223344
OUT_SIZE = 0x1E5C


def u64(x: int) -> int:
    return x & MASK64


def rol8(x: int, r: int) -> int:
    r &= 7
    return ((x << r) | (x >> (8 - r))) & 0xFF if r else x & 0xFF


def rol64(x: int, r: int) -> int:
    r &= 63
    return ((x << r) | (x >> (64 - r))) & MASK64 if r else x & MASK64


def mix64(x: int) -> int:
    x = u64((x ^ (x >> 30)) * MIX1)
    x = u64((x ^ (x >> 27)) * MIX2)
    return u64(x ^ (x >> 31))


def va_to_off(va: int) -> int:
    for _name, vma, size, off in SECTIONS:
        if vma <= va < vma + size:
            return off + (va - vma)
    raise ValueError(f"VA is outside known sections: 0x{va:x}")


def blob_at(exe: bytes, va: int, size: int) -> bytes:
    off = va_to_off(va)
    return exe[off:off + size]


class EncStream:
    """Encrypted byte stream used by the VM builder."""

    def __init__(self, program: bytes, seed: int = SEED1):
        self.program = program
        self.seed = seed
        self.pc = 0
        self.eof = False

    def key_for_index(self, i: int) -> int:
        x = u64(((i & 0xFFFFFFFF) << 32) ^ u64(self.seed + u64(GOLDEN * i)))
        x = mix64(x)
        return (x ^ (x >> 8) ^ (x >> 24) ^ (x >> 40)) & 0xFF

    def read_u8(self) -> int:
        if self.pc >= len(self.program):
            self.eof = True
            return 0
        i = self.pc
        self.pc += 1
        return self.program[i] ^ self.key_for_index(i)

    def read_u32(self) -> int:
        return sum(self.read_u8() << (8 * i) for i in range(4)) & 0xFFFFFFFF

    def read_i32(self) -> int:
        x = self.read_u32()
        return x - 0x100000000 if x & 0x80000000 else x

    def read_u64(self) -> int:
        return sum(self.read_u8() << (8 * i) for i in range(8)) & MASK64


def decode_builder_opcode(raw: int, tab_a: bytes, tab_b: bytes) -> int:
    a = tab_a[raw]
    b = tab_b[raw]
    if raw & 7:
        b = rol8(b, raw & 7)
    return (a ^ b ^ 0xA5) & 0xFF


def build_stage2_bytecode(exe: bytes) -> bytes:
    """Emulate sub_1400026A0 and return the generated 0x1e5c-byte program."""
    # Addresses recovered from IDA/Ghidra.
    enc_prog = blob_at(exe, 0x1400073A0, 116)
    const_table = blob_at(exe, 0x140007420, OUT_SIZE)
    tab_a = blob_at(exe, 0x140009280, 256)
    tab_b = blob_at(exe, 0x140009380, 256)

    out = bytearray(OUT_SIZE)
    regs = [0] * 8
    s = EncStream(enc_prog, SEED1)

    cmp_flag = False
    halted = False
    runtime_error = False
    watchdog = 2_000_000

    def reg_index() -> int:
        r = s.read_u8()
        if r >= 8:
            raise RuntimeError(f"bad register index {r} at pc={s.pc}")
        return r

    while watchdog > 0 and not runtime_error and not s.eof:
        watchdog -= 1
        raw = s.read_u8()
        op = decode_builder_opcode(raw, tab_a, tab_b)

        try:
            if op == 0x00:      # nop
                pass
            elif op == 0x01:    # reg[r] = imm64
                r = reg_index()
                regs[r] = s.read_u64()
            elif op == 0x02:    # mov
                a, b = reg_index(), reg_index()
                regs[a] = regs[b]
            elif op == 0x03:    # reg[a] = const_table[reg[b]]
                a, b = reg_index(), reg_index()
                if regs[b] >= len(const_table):
                    raise RuntimeError("const table OOB")
                regs[a] = const_table[regs[b]]
            elif op == 0x04:    # output[reg[a]] = low8(reg[b])
                a, b = reg_index(), reg_index()
                if regs[a] >= OUT_SIZE:
                    raise RuntimeError("output OOB")
                out[regs[a]] = regs[b] & 0xFF
            elif op == 0x05:    # xor qword
                a, b = reg_index(), reg_index()
                regs[a] = u64(regs[a] ^ regs[b])
            elif op == 0x06:    # xor low byte
                a, b = reg_index(), reg_index()
                regs[a] = (regs[a] & ~0xFF) | ((regs[a] ^ regs[b]) & 0xFF)
            elif op == 0x07:    # add qword
                a, b = reg_index(), reg_index()
                regs[a] = u64(regs[a] + regs[b])
            elif op == 0x08:    # add low byte
                a, b = reg_index(), reg_index()
                regs[a] = u64(regs[a] + (regs[b] & 0xFF))
            elif op == 0x09:    # add imm64
                r = reg_index()
                regs[r] = u64(regs[r] + s.read_u64())
            elif op == 0x0A:    # mul imm64
                r = reg_index()
                regs[r] = u64(regs[r] * s.read_u64())
            elif op == 0x0B:    # rol64(reg[a], raw_shift & 0x3f)
                a = reg_index()
                shift = s.read_u8() & 0x3F
                regs[a] = rol64(regs[a], shift)
            elif op == 0x0C:    # splitmix-style finalizer
                a = reg_index()
                regs[a] = mix64(regs[a])
            elif op == 0x0D:    # increment
                a = reg_index()
                regs[a] = u64(regs[a] + 1)
            elif op == 0x0E:    # less-than compare
                a, b = reg_index(), reg_index()
                cmp_flag = regs[a] < regs[b]
            elif op == 0x0F:    # conditional relative jump
                off = s.read_i32()
                if cmp_flag:
                    npc = s.pc + off
                    if npc < 0 or npc > len(s.program):
                        raise RuntimeError("bad jcc target")
                    s.pc = npc
            elif op == 0x10:    # unconditional relative jump
                off = s.read_i32()
                npc = s.pc + off
                if npc < 0 or npc > len(s.program):
                    raise RuntimeError("bad jmp target")
                s.pc = npc
            elif op == 0x11:    # halt
                halted = True
                break
            else:
                raise RuntimeError(f"bad opcode 0x{op:02x}")
        except RuntimeError as e:
            runtime_error = True
            raise

    if not halted or runtime_error or s.eof or watchdog == 0:
        raise RuntimeError("VM builder did not halt cleanly")
    return bytes(out)


def recover_flag_from_vm2_analysis() -> bytes:
    """
    Result of inverting the second VM verifier.

    In the write-up: VM2 groups the 32-byte input into four little-endian
    qwords, applies reversible Feistel-like rounds, then compares against
    constants. Inverting those rounds gives these four qwords.
    """
    recovered_qwords = [
        0x7B46544354495450,
        0x756A5F73315F6D76,
        0x3468735F345F7435,
        0x7D2133725F773064,
    ]
    return b"".join(struct.pack("<Q", x) for x in recovered_qwords)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("exe", nargs="?", default="GhostVM.exe")
    ap.add_argument("--dump-stage2", metavar="PATH", help="write generated VM2 bytecode")
    args = ap.parse_args()

    exe = Path(args.exe).read_bytes()
    stage2 = build_stage2_bytecode(exe)
    print(f"[+] stage2 bytecode size: 0x{len(stage2):x}")
    print(f"[+] stage2 first 16 bytes: {stage2[:16].hex()}")

    if args.dump_stage2:
        Path(args.dump_stage2).write_bytes(stage2)
        print(f"[+] wrote {args.dump_stage2}")

    flag = recover_flag_from_vm2_analysis()
    assert len(flag) == 32
    assert flag.startswith(b"PTITCTF{") and flag.endswith(b"}")
    print(f"[+] flag: {flag.decode()}")


if __name__ == "__main__":
    main()

Flag: PTITCTF{vm_1s_ju5t_4_sh4d0w_r3!}

Winux

Challenge cho ta file Winux.zip, bên trong gồm:

winux.exe
rootfs.wux
lib/winuxsvc.dll
lib/libpam_w32.dll

Triage qua DIE thì biết chương trình winux.exe viết bằng C++, nhưng không có gì đặc biệt.

image

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

image

Ok ngon vậy là ta đã có được flag.

Flag: PTITCTF{50m371m35_my_c011346u35_pr3f3r_u51n6_11nux_0v3r_w1nd0w5}

Có cái nịt, fake flag nhé ae =)))))).

Bật IDA Pro phân tích tĩnh winnux.exe. Không thấy có gì thú vị, nhưng mà ở hàm main mình thấy nó đang load library trong folder lib vào.

image

Hướng tiếp theo là phân tích xem thư viện winuxsvc.dll và libpam_w32.dll có gì.

Phân tích tĩnh winuxsvc.dll.

Phân tích tingx winuxsvc.dll trước. Có khá nhiều strings thú vị trong này:

image

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

image

Ok vậy là hàm WnxService là hàm duy nhất cần quan tâm (3 hàm còn lại là export mặc định, với Tls thường để check debug và DllEntryPoint là điểm bắt đầu của file dll).

Nhìn sơ qua thì hàm này đang mở rootfs.wux, check magic number:

image

0x32585557 là little-endian của WUX2

Sau đó chương trình parse các entry trong rootfs.wux, rồi xử lý các request tương ứng với path như /root/flag.txt, /root/flag, /etc/os-release, /etc/sudoers, v.v.

Thứ ta cần quan tâm là case 4. Đây là khu vực check flag:

image

    case 4:
      v24 = *(_QWORD *)&v75[0];
      if ( strcmp((const char *)(*(_QWORD *)&v75[0] + 292LL), "/root/flag.txt")
        && strcmp((const char *)(v24 + 292), "/root/flag") )
      {
        sub_3665F5CB0((char *)(*((_QWORD *)&v75[0] + 1) + 24LL), 0x800u, "grep: %s: No such file or directory\n");
        return (unsigned int)a2[1];
      }
      if ( *(_DWORD *)(v24 + 16) == 4 && (v30 = sub_3665F1570("real", 0, v24), v24 = *(_QWORD *)&v75[0], v30)
        || *(_DWORD *)(v24 + 8)
        || !*(_DWORD *)(v24 + 12) )
      {
        strcpy(winux:char:v3, "winux:char:v3");
        v25 = strlen((const char *)(v24 + 866)) == 64;
        v26 = 0;
        if ( v25 )
        {
          n64 = 0;
          v72 = &v64;
          v28 = (char *)&unk_3665F7B40;
          Str2_ = _mm_unpacklo_epi64((__m128i)(unsigned __int64)winux:char:v3, (__m128i)(unsigned __int64)&n64_1);
          *(__m128i *)Str2 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8060);
          v74 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8070);
          do
          {
            n64_1 = n64;
            v66 = 0;
            v67 = 0;
            v29 = byte_3665F7F40[n64] ^ *(_BYTE *)(v24 + n64 + 866);
            v71 = v28;
            v64 = v29;
            sub_3665F48A0(Str2_1, &Str2_, Str2, 4);
            v26 = sub_3665F4B90(Str2_1, (char *)&unk_3665F7340 + 32 * n64, 32);
            if ( !v26 )
            {
              v24 = *(_QWORD *)&v75[0];
              goto LABEL_46;
            }
            ++n64;
            v28 += 16;
          }
          while ( n64 != 64 );
          v24 = *(_QWORD *)&v75[0];
          v26 = 1;
        }
LABEL_46:
        Str2_.m128i_i32[0] = v26;
        *(_DWORD *)Str2 = sub_3665F3EF0(v26, *(unsigned int *)(v24 + 1202), *(unsigned int *)(v24 + 24));
        goto LABEL_47;
      }
      v44 = *((_QWORD *)&v75[0] + 1);
      Permission_denied__Try_running_as_Administrator_n_2 = "Permission denied! Try running as Administrator\n";
      for ( j = 0; j != 2047; *(_BYTE *)(v44 + j++ + 24) = *Permission_denied__Try_running_as_Administrator_n_2++ )
      {
        if ( !*Permission_denied__Try_running_as_Administrator_n_2 )
          break;
      }
      goto LABEL_77;

Đầu tiên, case 4 kiểm tra path đang grep là /root/flag.txt hoặc /root/flag. Đoạn check flag/hash 64 ký tự bắt đầu rõ nhất ở đây:

strcpy(winux:char:v3, "winux:char:v3");
v25 = strlen((const char *)(v24 + 866)) == 64;

Tại đây nó đang lấy chuỗi input ở v24 + 866 rồi yêu cầu độ dài là đúng 64.

Sau đó chương trình chạy vòng lặp check ký tự:

        strcpy(winux:char:v3, "winux:char:v3");
        v25 = strlen((const char *)(v24 + 866)) == 64;
        v26 = 0;
        if ( v25 )
        {
          n64 = 0;
          v72 = &v64;
          v28 = (char *)&unk_3665F7B40;
          Str2_ = _mm_unpacklo_epi64((__m128i)(unsigned __int64)winux:char:v3, (__m128i)(unsigned __int64)&n64_1);
          *(__m128i *)Str2 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8060);
          v74 = _mm_loadu_si128((const __m128i *)&xmmword_3665F8070);
          do
          {
            n64_1 = n64;
            v66 = 0;
            v67 = 0;
            v29 = byte_3665F7F40[n64] ^ *(_BYTE *)(v24 + n64 + 866);
            v71 = v28;
            v64 = v29;
            sub_3665F48A0(Str2_1, &Str2_, Str2, 4);
            v26 = sub_3665F4B90(Str2_1, (char *)&unk_3665F7340 + 32 * n64, 32);
            if ( !v26 )
            {
              v24 = *(_QWORD *)&v75[0];
              goto LABEL_46;
            }
            ++n64;
            v28 += 16;
          }
          while ( n64 != 64 );
          v24 = *(_QWORD *)&v75[0];
          v26 = 1;
        }

Tại đây chương trình check từng byte của input bằng cách XOR với byte_3665F7F40[n64], hash bằng sub_3665F48A0, rồi so sánh digest với bảng hash tại unk_3665F7340 + 32 * n64.

Bởi vì chương trình check từng byte một cách độc lập => có thể brute force từng byte một.

Các vùng dữ liệu dùng cho check:

expected hash table : unk_3665F7340 (RVA 0x7340), size 64 * 32
salt table          : unk_3665F7B40 (RVA 0x7B40), size 64 * 16
xor mask            : byte_3665F7F40 (RVA 0x7F40), size 64

Script solve:

#!/usr/bin/env python3
import hashlib
import struct
import sys
import zipfile
from pathlib import Path


EXPECTED_RVA = 0x7340   # unk_3665F7340, 64 * 32 bytes
SALT_RVA     = 0x7B40   # unk_3665F7B40, 64 * 16 bytes
MASK_RVA     = 0x7F40   # byte_3665F7F40, 64 bytes

PREFIX = b"winux:char:v3"


def load_dll(path: str) -> bytes:
    p = Path(path)

    if p.suffix.lower() == ".zip":
        with zipfile.ZipFile(p, "r") as z:
            # Trong challenge, DLL nằm ở lib/winuxsvc.dll
            return z.read("lib/winuxsvc.dll")

    return p.read_bytes()


def parse_sections(pe: bytes):
    if pe[:2] != b"MZ":
        raise ValueError("Not a PE file")

    pe_off = struct.unpack_from("<I", pe, 0x3C)[0]

    if pe[pe_off:pe_off + 4] != b"PE\x00\x00":
        raise ValueError("Invalid PE signature")

    num_sections = struct.unpack_from("<H", pe, pe_off + 6)[0]
    opt_header_size = struct.unpack_from("<H", pe, pe_off + 20)[0]

    section_table = pe_off + 24 + opt_header_size
    sections = []

    for i in range(num_sections):
        off = section_table + i * 40

        name = pe[off:off + 8].rstrip(b"\x00").decode(errors="ignore")
        virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from(
            "<IIII",
            pe,
            off + 8
        )

        sections.append({
            "name": name,
            "va": virtual_address,
            "vsize": virtual_size,
            "raw_size": raw_size,
            "raw_ptr": raw_ptr,
        })

    return sections


def rva_to_offset(rva: int, sections) -> int:
    for s in sections:
        start = s["va"]
        size = max(s["vsize"], s["raw_size"])
        end = start + size

        if start <= rva < end:
            return s["raw_ptr"] + (rva - start)

    raise ValueError(f"Cannot convert RVA {hex(rva)} to file offset")


def recover_flag(pe: bytes) -> bytes:
    sections = parse_sections(pe)

    expected_off = rva_to_offset(EXPECTED_RVA, sections)
    salt_off = rva_to_offset(SALT_RVA, sections)
    mask_off = rva_to_offset(MASK_RVA, sections)

    expected = pe[expected_off:expected_off + 64 * 32]
    salts = pe[salt_off:salt_off + 64 * 16]
    mask = pe[mask_off:mask_off + 64]

    flag = bytearray()

    for i in range(64):
        target_digest = expected[i * 32:(i + 1) * 32]
        salt = salts[i * 16:(i + 1) * 16]

        found = None

        for c in range(256):
            msg = (
                PREFIX
                + struct.pack("<I", i)
                + salt
                + bytes([c ^ mask[i]])
            )

            digest = hashlib.sha256(msg).digest()

            if digest == target_digest:
                found = c
                break

        if found is None:
            raise RuntimeError(f"Cannot recover byte at index {i}")

        flag.append(found)

    return bytes(flag)


def main():
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} Winux.zip")
        print(f"   or: {sys.argv[0]} lib/winuxsvc.dll")
        sys.exit(1)

    pe = load_dll(sys.argv[1])
    flag = recover_flag(pe)

    print(flag.decode(errors="replace"))


if __name__ == "__main__":
    main()

image

Flag: PTITCTF{1_7h1nk_y0u_kn0w_h0w_70_cr4ck_p1r473d_50f7w4r3_R19H7???}

Process

Challenge này yêu cầu mở instance. Sau đó ta sẽ tải file challenge từ instance này. Challenge cung cấp một file chall.exe.

Triage cơ bản với DIE.

image

Ở đây DIE báo có packer, có vẻ section .rdata có packed code.

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

image

Ok ở đây ta có một cái flag (tất nhiên là flag đểu :v).

Load vào IDA Pro để phân tích tĩnh. Tìm tới hàm main:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  _BYTE proc_name[48]; // [rsp+30h] [rbp-90h] BYREF
  _QWORD v5[11]; // [rsp+60h] [rbp-60h] BYREF
  unsigned int pid; // [rsp+B8h] [rbp-8h]
  int n1613; // [rsp+BCh] [rbp-4h]

  sub_140002730();
  n1613 = 1613;
  if ( (unsigned __int8)check_debug() )
  {
    print_fake_flag();
    return 0;
  }
  else if ( sub_14000183F(v5) )
  {
    create_packed_proc_name((__int64)proc_name);
    pid = find_pid((__int64)v5, (__int64)proc_name);
    if ( pid )
    {
      if ( (unsigned int)inject_3_bytes_and_run((__int64)v5, pid) )
        puts("The sealed page changed somewhere else.");
      else
        print_fake_flag();
      return 0;
    }
    else
    {
      print_fake_flag();
      return 0;
    }
  }
  else
  {
    print_fake_flag();
    return 0;
  }
}

Mình phân tích để xem xem các hàm trong main đang thực hiện việc gì. Sau đó rename lại các hàm và được kết quả như trên. Vì các hàm cũng khá ngắn và dễ hiểu nên mình sẽ không giải thích bước này.

Tóm tắt thì chương trình xor dữ liệu với 0x97 để tạo một string "1dRxp.exe" là tên của một process. Khi có một process với tên như vậy đang chạy, chương trình sẽ không in decoy flag nữa mà sẽ in ra The sealed page changed somewhere else..

Nhớ lại lúc đầu khi triage DIE có phát hiện packed data ở section .rdata.

image

Thấy cái header lạ lạ. Tuy nhiên tra cứu thì không thấy kết quả gì cả. Có vẻ đây là một header custom.

Dump section này ra và cho LLMs phân tích. Có thể rút ra được thông tin sau từ section này:

total length: 0x64d
encrypted payload length: 0x577
fake XOR key: 0x28 (cái này sinh ra decoy flag lúc đầu)
trailer field lengths: 0x16, 0x9, 0x11, 0x4, 0x4, 0x4, 0x4
stream key: 8f69ab60f395305b72f90504b98eaf38
salt: 1drxp

Ok vậy hướng đi bây giờ là cần tìm xem trong binary kia chỗ nào đang chịu trách nhiệm cho việc decrypt section .rdata. Sau khi mò một hồi thì mình thấy hàm khá sus:

__int64 __fastcall sub_140002379(__int64 a1, unsigned int i, int a3)
{
  __int64 j_1; // rax
  unsigned int v4; // [rsp+38h] [rbp-C8h] BYREF
  unsigned int v5; // [rsp+3Ch] [rbp-C4h] BYREF
  int v6[6]; // [rsp+40h] [rbp-C0h] BYREF
  _BYTE p_a2_1[4]; // [rsp+58h] [rbp-A8h] BYREF
  int a2; // [rsp+5Ch] [rbp-A4h] BYREF
  __int64 a4; // [rsp+60h] [rbp-A0h] BYREF
  unsigned __int16 v10[2]; // [rsp+68h] [rbp-98h] BYREF
  unsigned __int16 v11; // [rsp+6Ch] [rbp-94h] BYREF
  unsigned __int16 v12[2]; // [rsp+70h] [rbp-90h] BYREF
  unsigned __int16 v13[6]; // [rsp+74h] [rbp-8Ch] BYREF
  __int64 p_a2[13]; // [rsp+80h] [rbp-80h] BYREF
  int v15; // [rsp+ECh] [rbp-14h]
  int v16; // [rsp+F0h] [rbp-10h]
  unsigned int i_1; // [rsp+F4h] [rbp-Ch]
  unsigned int k; // [rsp+F8h] [rbp-8h]
  unsigned int j; // [rsp+FCh] [rbp-4h]

  i_1 = sub_1400021C2((__int64)p_a2, 0x60u);
  sub_140002073((__int64)p_a2, i_1, a3, (__int64)&a4, 0x18u);
  v6[0] = sub_140001F02((unsigned __int16 *)&a4);
  v6[1] = sub_140001F02((unsigned __int16 *)&a4 + 2);
  v6[2] = sub_140001F02(v10);
  v6[3] = sub_140001F02(&v11);
  v16 = sub_140001F02(v12);
  v15 = sub_140001F02(v13);
  for ( j = 0; ; j += 8 )
  {
    j_1 = j;
    if ( j >= i )
      break;
    v5 = v16 ^ (j >> 3);
    v4 = a3 ^ v15;
    sub_140002273(&v5, (int *)&v4, v6);
    sub_140001F4F((__int64)p_a2_1, v5);
    sub_140001F4F((__int64)&a2, v4);
    for ( k = 0; k <= 7 && j + k < i; ++k )
      *(_BYTE *)(j + k + a1) ^= p_a2_1[k];
  }
  return j_1;
}

Trace sâu hơn vào các hàm con của hàm này. Và ta thấy nó đang gọi một hằng số khá sus:

image

Đây là magic byte của hàm hash Fowler–Noll–Vo (FNV).

Tới đây thì có thể quăng cho LLMs decrypt theo hàm này được rồi.

Thuật toán mã hóa trong sub_140002379 sinh keystream bằng cách dùng:

seed FNV-1a 64-bit: 0xcbf29ce484222325
config key 16 byte
nonce field 32-bit
SplitMix64
mixer 32-bit chạy 27 vòng

Phần trailer cuối dài 64 byte được chia theo các độ dài field ở trên, sau đó từng field được giải mã độc lập với nonce từ 0..6. Sau khi nối các field lại, ta thu được:

50m371m35_1nf0rm4710n_1n_420e3611bb577_pr0c355_15_v3ry_1mp0r74n7

Script solve:

import re
import struct
import sys
from pathlib import Path


FLAG_RE = re.compile(rb"PTITCTF\{[ -~]+\}")


def rol32(value: int, count: int) -> int:
    value &= 0xFFFFFFFF
    return ((value << count) & 0xFFFFFFFF) | (value >> (32 - count))


def ror32(value: int, count: int) -> int:
    value &= 0xFFFFFFFF
    return (value >> count) | ((value << (32 - count)) & 0xFFFFFFFF)


def read_u32(data: bytes) -> int:
    return struct.unpack("<I", data)[0]


def write_u32(value: int) -> bytes:
    return struct.pack("<I", value & 0xFFFFFFFF)


def fnv64(state: int, data: bytes) -> int:
    for byte in data:
        state = (0x100000001B3 * (byte ^ state)) & 0xFFFFFFFFFFFFFFFF
    return state


def splitmix_next(state: int) -> tuple[int, int]:
    state = (state - 0x61C8864680B583EB) & 0xFFFFFFFFFFFFFFFF
    mixed = (0xBF58476D1CE4E5B9 * (state ^ (state >> 30))) & 0xFFFFFFFFFFFFFFFF
    mixed = (0x94D049BB133111EB * (mixed ^ (mixed >> 27))) & 0xFFFFFFFFFFFFFFFF
    return state, (mixed ^ (mixed >> 31)) & 0xFFFFFFFFFFFFFFFF


def stream_seed(salt: bytes, key: bytes, nonce: int, size: int) -> bytes:
    state = fnv64(0xCBF29CE484222325, salt)
    state = fnv64(state, key)
    state = fnv64(state, write_u32(nonce))
    out = bytearray()
    while len(out) < size:
        state, value = splitmix_next(state)
        out.extend(struct.pack("<Q", value))
    return bytes(out[:size])


def mix_round(left: int, right: int, key_words: list[int]) -> tuple[int, int]:
    rolling = key_words[0]
    work = key_words[1:4]

    for index in range(27):
        old = rolling
        left = (old ^ ((right + ror32(left, 8)) & 0xFFFFFFFF)) & 0xFFFFFFFF
        right = (rol32(right, 3) ^ left) & 0xFFFFFFFF
        slot = index % 3
        work[slot] = (index ^ ((old + ror32(work[slot], 8)) & 0xFFFFFFFF)) & 0xFFFFFFFF
        rolling = (work[slot] ^ rol32(old, 3)) & 0xFFFFFFFF

    return left, right


def decrypt_field(data: bytes, salt: bytes, key: bytes, nonce: int) -> bytes:
    seed = stream_seed(salt, key, nonce, 24)
    key_words = [read_u32(seed[offset:offset + 4]) for offset in range(0, 16, 4)]
    base_left = read_u32(seed[16:20])
    base_right = read_u32(seed[20:24])

    out = bytearray(data)
    for offset in range(0, len(out), 8):
        left = base_left ^ (offset >> 3)
        right = nonce ^ base_right
        left, right = mix_round(left, right, key_words)
        stream = write_u32(left) + write_u32(right)
        for index in range(min(8, len(out) - offset)):
            out[offset + index] ^= stream[index]
    return bytes(out)


def extract_flag(binary_path: Path) -> bytes:
    blob = binary_path.read_bytes()
    config_offset = blob.index(b"PESCFGv3M")
    values = list(struct.unpack_from("<15I", blob, config_offset + 8))

    total_len = values[0]
    process_len = values[3]
    decoy_len = values[5]
    encrypted_payload_len = values[6]
    key_len = values[4]
    key_offset = config_offset + 8 + 15 * 4
    process_offset = key_offset + key_len
    decoy_offset = process_offset + process_len
    trailer_offset = decoy_offset + decoy_len + encrypted_payload_len

    key = blob[key_offset:process_offset]
    process_name = blob[process_offset:decoy_offset]
    salt = process_name.split(b".e")[0].lower()

    field_lengths = values[8:11] + values[11:15]
    if sum(field_lengths) != total_len - (trailer_offset - config_offset):
        raise RuntimeError("Unexpected PESCFG trailer layout")

    flag_body = bytearray()
    cursor = trailer_offset
    for nonce, field_len in enumerate(field_lengths):
        encrypted_field = blob[cursor:cursor + field_len]
        flag_body.extend(decrypt_field(encrypted_field, salt, key, nonce))
        cursor += field_len

    flag = b"PTITCTF{" + bytes(flag_body) + b"}"
    if not FLAG_RE.fullmatch(flag):
        raise RuntimeError(f"Decoded bytes do not look like a flag: {flag!r}")
    return flag


def main() -> int:
    binary_path = Path(__file__).with_name("chall.exe")
    print(extract_flag(binary_path).decode())
    return 0


if __name__ == "__main__":
    sys.exit(main())

Flag: PTITCTF{50m371m35_1nf0rm4710n_1n_420e3611bb577_pr0c355_15_v3ry_1mp0r74n7}.

Pablo

Bài này có 2 phase, phase đầu tiên là REV do mình làm. Phase sau là web, do @dthangws làm. Thật ra bài này phase REV khá dễ.

Challenge cung cấp 2 file là Tool.exe và config.enc.

Xem qua file config.enc:

{
  "version": 1,
  "kdf": "pbkdf2-sha256",
  "iterations": 210000,
  "salt": "91fS3SAVqIPnMb8UEB0GWA",
  "nonce": "5Oql/E6+RenWpfIQ",
  "ciphertext": "xSPz+ilj6AV+cHnhMOjfHw3d/HW01qx3Ge+d6SoXh4cUL3BiI0ba3QpK2hUSu0FMnsar79NbH+kgeBKvy5TF8dc0EvzuNAcCqHeWrnwZ2q5+dkPp/iMZPWvgfbABpXea4VwObA/MRTf9d7R12NY7f/3glTgHthaWO9Ca8Poqg+A9Kh11rM2o9z5iBO6s0IaImahsJqCNYhxHYcPogdEQSKP73tJZqWetRRW/SsuX9ZTta+rV6WY8KOjDKmGWlHgxjFNug9d6Y+oG85qpJklTCr3EF9ZOHWxmtpC8wn3vwEQPbkrCRC6mgbAxGw"
}

Như vậy file này chứa thông tin mã hoá, gồm thuật toán mã hoá là pbkdf2-sha256 và đầy đủ salt, nonce, ciphertext. Nhiệm vụ bây giờ là tìm key để decrypt thôi.

DIE báo binary viết bằng go:

image

Load luôn vào IDA Pro:

image

image

Như vậy là binary còn nguyên hàm configKey thực hiện việc sinh key.

// configurable-webview/internal/configkey.Key
__int64 configurable_webview_internal_configkey_Key()
{
  __int64 result; // rax
  __int64 i; // rcx

  result = runtime_makeslice(&RTYPE_uint8, 44, 44);
  for ( i = 0; i < 44; ++i )
    *(_BYTE *)(result + i) = *((_BYTE *)&xmmword_140297C80 + i) ^ *((_BYTE *)&xmmword_140297CC0 + i) ^ (73 * i + 93);
  return result;
}

Hàm này khá đơn giản. Để giải mã thì dump xmmword_140297C80 và xmmword_140297CC0 rồi XOR ngược lại là xong :v.

Decrypt key:

key = bytes(
    obf_a[i] ^ obf_b[i] ^ ((73 * i + 0x5d) & 0xff)
    for i in range(0x2c)
)
print(key.decode())

Dùng key để decrypt config.enc, ta được kết quả sau:

{
  "url": "http://144.79.188.36:6500/",
  "title": "WebView Tool",
  "width": 1280,
  "height": 800,
  "min_width": 640,
  "min_height": 480,
  "resizable": true,
  "center": true,
  "debug": false,
  "data_dir": ""
}

Truy cập http://144.79.188.36:6500/ với tài khoàn mật khẩu là luongvd.

Khúc sau mọi người đọc writeup của @dthangws nha.

Đoạn này là tìm được account của admin tk: admin, mk: Admin@1234.

image

Tiger Bạc

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

image

Để ý cái ảnh này trong post tiktok.

Sau đó dùng sherlock + maigret, minhincs OSINT ra được repo này, giải nén file zip bằng mật khâu là luongvd1234! suy ra từ cái post tiktok ở trên.

https://gitlab.com/luonginfosec/checker

Tới đây mình nghĩ là bài này ngon ăn, nhưng mà thế nào làm toàn ra decoy flag, đặc biệt là còn ra cả một file README.txt nội dung thế này.

Congratulations. This archive is an intentionally convincing dead end.

Sau đó mình nghĩ là cả cái repo là decoy ngay từ đầu nên không thử đào sâu hơn nữa, rồi quay ra OSINT tiếp =)))). Nhưng mà có vẻ cái repo này ra flag real.

Khá tiếc khi mình không ra được flag bài này, đây là các decoy flag mình làm ra:

PTITCTF{th3_n1nth_g4t3_0pens_4t_09_17}
PTITCTF{d3bug_m0d3_always_tells_the_truth}
PTITCTF{the_metadata_was_the_flag}
PTITCTF{binwalk_found_the_real_secret}
PTITCTF{ai_solved_this_in_one_prompt}
PTITCTF{strings_is_all_you_need}
PTITCTF{just_patch_the_final_jump}
PTITCTF{trust_the_blue_channel}
PTITCTF{nine_is_a_red_herring}
PTITCTF{the_monolith_is_watching_you}
PTITCTF{crc32_is_cryptography}

Vậy là mình kết thúc với 6/7 bài REV. Các challenge mùa giải áp dụng nhiều mảng với nhau. Lời cuối xin phép cảm ơn ban tổ chức vì những challenge rất thú vị!