WolvCTF 2025 Writeup

Office, AngerIssues, CrackMeEXE, Passwords

나른하던 일요일 오후, 선배에게 카톡 한 통을 받았다.

image.png


12시까지 잠들지 않으면 월요병 증세가 심해지므로… 자체 6시간 타임 리밋 CTF 렛츠고

image.png


Problem Type Remarks
Passwords
Forensics
KeePass Database Brute-Force
CrackMeEXE
Reversing
UPX Unpacking
AngerIssues
Reversing
Symbolic Condition Extraction
Office
Reversing
Bitmask based balance manipulation


Passwords

kdbx 파일 1개가 주어진다. kdbxKeePass라는 비밀번호 관리 프로그램의 데이터베이스 파일 형식이다. 내용을 바로 열람 가능할 수도 있지만 보통 데이터베이스 암호화 해제를 위한 마스터 키가 필요하다.

└─$ file Database.kdbx 
Database.kdbx: Keepass password database 2.x KDBX


PoC

keepass2john으로 해시를 덤프하고, 이를 brute-force attack 하여 쉽게 마스터 키를 얻을 수 있었다.

┌──(kali㉿kali)-[~/Desktop]
└─$ keepass2john Database.kdbx > hash.txt
                                                                            
┌──(kali㉿kali)-[~/Desktop]
└─$ john hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (KeePass [SHA256 AES 32/64])
Cost 1 (iteration count) is 6000 for all loaded hashes
Cost 2 (version) is 2 for all loaded hashes
Cost 3 (algorithm [0=AES 1=TwoFish 2=ChaCha]) is 0 for all loaded hashes
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
goblue1          (Database)     
1g 0:00:00:36 DONE (2025-03-23 03:52) 0.02758g/s 2459p/s 2459c/s 2459C/s gobucks1..giana
Use the "--show" option to display all of the cracked passwords reliably
Session completed. 
                                                                            
┌──(kali㉿kali)-[~/Desktop]
└─$ john --show hash.txt                                     
Database:goblue1

1 password hash cracked, 0 left


아래와 같이 데이터베이스 내 주요 필드를 출력하여 Flag를 찾을 수 있다.

from pykeepass import PyKeePass

kp = PyKeePass('Database.kdbx', password='goblue1')

for entry in kp.entries:
    print(f"[{entry.title}]")
    print(f"  Username: {entry.username}")
    print(f"  Password: {entry.password}")
    print(f"  Notes: {entry.notes}")
    print()
[Sample Entry]
  Username: User Name
  Password: Password
  Notes: Notes

...

[flag]
  Username: the flag
  Password: wctf{1_th0ught_1t_w4s_s3cur3?}
  Notes: :3


CrackMeEXE

exe 파일 1개가 주어진다. 실행 시 올바른 패스워드 입력을 요구한다.

image.png


UPX Unpacking

IDA에 문제 파일을 로드하자 IAT 로드 관련 에러가 발생하였다.

image.png


때문에 PE 구조를 확인해보니 UPX 패킹이 되어 있었다.

image.png


그렇다면 언패킹을 한 뒤 IDA에 다시 올리면 된다.


$ upx -d chall_unpack.exe
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2024
UPX 4.2.2       Markus Oberhumer, Laszlo Molnar & John Reiser    Jan 3rd 2024

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
     12289 <-      8705   70.84%    win64/pe     chall_unpack.exe

Unpacked 1 file.


PoC

디버거 체크 로직 2개 if ( ((__int64 (*)(void))IsDebuggerPresent)() )!((unsigned int (__fastcall *)(__int64, int *))CheckRemoteDebuggerPresent)(v15, &v24)만 잘 우회하면 비밀번호 체크 로직인 v22 = v16(Buffer);로 진입할 수 있다. 디버거 체크 로직은 로직 실행 후 raxv24(stack) 값을 적절히 바꾸어 주는 것으로 간단히 우회 가능하다.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  ...
  if ( ((__int64 (*)(void))IsDebuggerPresent)() )
  {
    puts("Nice try");
    exit(-1);
  }
  v13 = v12();
  v14 = ((__int64 (__fastcall *)(__int64, _QWORD, _QWORD))OpenProcess)(0x1FFFFFLL, 0LL, v13);
  v15 = v14;
  if ( !v14 )
    return -1;
  v16 = (__int64 (__fastcall *)(char *))((__int64 (__fastcall *)(__int64, _QWORD, __int64, __int64, int))VirtualAllocEx)(
                                          v14,
                                          0LL,
                                          139LL,
                                          12288LL,
                                          4);
  if ( !v16 )
    return -2;
  srand(0x3419u);
  v18 = 0;
  v19 = &unk_7FF618405080;
  do
  {
    *v19 ^= rand();
    ++v18;
    ++v19;
  }
  while ( v18 < 0x8B );
  if ( !((unsigned int (__fastcall *)(__int64, __int64 (__fastcall *)(char *), void *, __int64, char *))WriteProcessMemory)(
          v15,
          v16,
          &unk_7FF618405080,
          139LL,
          v25) )
    return -3;
  if ( !((unsigned int (__fastcall *)(__int64, __int64 (__fastcall *)(char *), __int64))VirtualProtectEx)(
          v15,
          v16,
          139LL) )
    return -4;
  puts("What is the password?\n");
  if ( !((unsigned int (__fastcall *)(__int64, int *))CheckRemoteDebuggerPresent)(v15, &v24) )// v24 needs patch : 1->0 , not rax!!
    return -5;
  if ( v24 )
  {
    puts("NO CHEATING");
    exit(-1);
  }
  v27 = 0;
  *(_OWORD *)Buffer = 0LL;
  v20 = _acrt_iob_func(0);
  fgets(Buffer, 19, v20);
  v21 = strcspn(Buffer, "\n");
  if ( v21 >= 0x14 )
    _report_rangecheckfailure();
  Buffer[v21] = 0;
  v22 = v16(Buffer);
  v23 = "\nCORRECT!";
  if ( v22 )
    v23 = "\nWhat? no...";
  puts(v23);
  return 0;
}


우회 후 체크 로직에 진입하면, v4, v5 값을 활용한 간단한 XOR 연산으로 비밀번호를 체크하는 로직임을 확인 가능하다.

__int64 __fastcall sub_1CEB7450000(__int64 buffer)
{
  __int64 i; // r10
  __int64 v3; // rdi
  _QWORD v4[4]; // [rsp-28h] [rbp-28h] BYREF
  __int64 v5; // [rsp-8h] [rbp-8h] BYREF

  for ( i = 0LL; *(_BYTE *)(buffer + i); ++i )
    ;
  if ( i != 18 )
    return 1LL;
  v5 = 1734437990LL;
  v4[3] = &v5;
  v4[2] = 4441LL;
  v4[1] = 0x1352353903521556LL;
  v4[0] = 0x90F2D1D01150F11LL;
  v3 = 0LL;
  while ( i )
  {
    v3 += *((_BYTE *)v4 + i - 1) != (unsigned __int8)(*((_BYTE *)&v5 + (i - 1) % 4uLL) ^ *(_BYTE *)(buffer + i - 1));
    --i;
  }
  return v3;
}


아래와 같이 역연산 코드를 짜 flag를 구할 수 있다.


import struct

# v4 데이터 (총 32바이트)
v4_bytes = (
    struct.pack("<Q", 0x90F2D1D01150F11) +       # v4[0]
    struct.pack("<Q", 0x1352353903521556) +      # v4[1]
    struct.pack("<Q", 4441) +                    # v4[2] (4B 유효)
    struct.pack("<Q", 0x0)                       # dummy for alignment
)

v5 = struct.pack("<I", 0x67616C66) 

password = b""

for i in range(18):
    expected = v4_bytes[i]
    mask = v5[i % 4]
    password += bytes([expected ^ mask])

print("Recovered password:", password.decode('latin1'))  


AngerIssues

ELF 한 개가 주어지며, 실행 시 적절한 60자 입력을 요구한다.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  printf("Enter the secret string: ");
  fgets(input, 60, stdin);
  checks((__int64)input);
  puts("Yay! You did it!");
  return 0;
}


입력값 체크 로직 형태를 보면 내면에서 잠시 Anger Issue가 발생한다.

__int64 __fastcall checks(__int64 a1)
{
  base(a1);
  func0(a1);
  func1();
  func2(a1);
  func3(a1);
  func4(a1);
  func5(a1);
  func6(a1);
  func7(a1);
  func8(a1);
  func9(a1);
  func10(a1);
  ...
  func232(a1);
  func233(a1);
  func234(a1);
  func235(a1);
  func236(a1);
  func237(a1);
  func238(a1);
  return func239(a1);
}


Approach

다행히 각 func 형태가 복잡하지 않으므로, 금새 Anger Issue를 가라앉힐 수 있었다. 분노조절잘해

__int64 __fastcall func0(__int64 a1)
{
  __int64 result; // rax

  result = (unsigned int)*(char *)(a1 + 12);
  if ( (_DWORD)result != *(char *)(a1 + 24) + 39 )
    errorFunc();
  return result;
}


대부분의 func는 위와 같이 array[a1] == array[a2] + a3 형태이거나 아무 내용도 담고 있지 않다. 분석을 위해 어셈블리를 보면 아래와 같다. 형태가 일정하므로, IDA python 스크립트를 활용하여 각 func{n}으로부터 array[a1] == array[a2] + a3a1, a2, a3를 추출하는 쪽으로 문제 풀이 방향을 설정하였다.

image.png


PoC

각 func{n} 심볼로부터 함수의 주소 값을 파악하고, array[a1] == array[a2] + a3a1, a2, a3를 추출하는 IDA Python 코드이다.

from idautils import *
from idc import *

def parse_func_offsets(ea):
    insts = list(FuncItems(ea))
    reg_map = {}      # 레지스터가 기준 주소를 가리키고 있는지 (e.g., a1)
    mem_reads = {}    # 레지스터가 메모리에서 어떤 오프셋을 읽었는지
    result = None

    for i in insts:
        mnem = print_insn_mnem(i)

        if mnem == "mov" and "[rbp+var_8]" in print_operand(i, 1):
            reg = print_operand(i, 0)
            reg_map[reg] = 0

        elif mnem == "add":
            reg = print_operand(i, 0)
            val = get_operand_value(i, 1)
            if reg in reg_map:
                reg_map[reg] += val
            elif reg in mem_reads:
                prev = mem_reads[reg]
                if isinstance(prev, tuple):
                    offset, const = prev
                else:
                    offset, const = prev, 0
                mem_reads[reg] = (offset, const + val)

        elif mnem == "sub":
            reg = print_operand(i, 0)
            val = get_operand_value(i, 1)
            if reg in mem_reads:
                prev = mem_reads[reg]
                if isinstance(prev, tuple):
                    offset, const = prev
                else:
                    offset, const = prev, 0
                mem_reads[reg] = (offset, const - val)

        elif mnem in ["movzx", "movsx"]:
            dst = print_operand(i, 0)
            src = print_operand(i, 1)
            if "byte ptr" in src:
                for reg, offset in reg_map.items():
                    if reg in src:
                        mem_reads[dst] = offset

        elif mnem == "cmp":
            op1 = print_operand(i, 0)
            op2 = print_operand(i, 1)

            def resolve(op):
                if op in mem_reads:
                    val = mem_reads[op]
                    if isinstance(val, tuple):
                        return val
                    else:
                        return (val, 0)
                return None

            val1 = resolve(op1)
            val2 = resolve(op2)

            if val1 and val2:
                offset1, const1 = val1
                offset2, const2 = val2
                result = (offset1, offset2, const2 - const1)
                break

    return result


def parse_all_funcs():
    results = []
    for i in range(240):
        name = f"func{i}"
        ea = get_name_ea_simple(name)
        if ea == BADADDR:
            print(f"[!] {name} not found.")
            continue

        res = parse_func_offsets(ea)
        if res:
            print(f"[+] {name}: {res}")
            results.append((name, *res))
        else:
            print(f"[-] {name}: no match")
    return results

parse_all_funcs()


추출 결과를 conditions.txt에 저장한 다음, z3을 이용하여 조건을 만족하는 입력값을 알아내었다.

from z3 import *

def parse_conditions_from_file(filename):
    conditions = []
    with open(filename, 'r') as f:
        for line in f:
            if line.startswith("[+]"):
                try:
                    parts = line.split(":")
                    values = parts[1].strip().strip("()").split(",")
                    a1, a2, a3 = map(int, values)
                    conditions.append((a1, a2, a3))
                except:
                    continue
    print(f"[+] Parsed {len(conditions)} conditions")
    return conditions

def solve_with_z3(conditions):
    max_index = max(max(a1, a2) for a1, a2, _ in conditions)
    max_index = max(max_index, 42) + 1

    array = [Int(f'array_{i}') for i in range(max_index)]
    s = Solver()

    # 바이트 범위 제한
    for b in array:
        s.add(b >= 0, b <= 255)

    # 조건들 추가
    for a1, a2, a3 in conditions:
        s.add(array[a1] == array[a2] + a3)

    # 초기 조건 (wctf, 마지막 문자 == 'w')
    s.add(array[0] == 119)  # 'w'
    s.add(array[1] == 99)   # 'c'
    s.add(array[2] == 116)  # 't'
    s.add(array[3] == 102)  # 'f'
    s.add(array[42] == array[0])  # array[42] == 'w'

    if s.check() == sat:
        model = s.model()
        flag_bytes = [
            model.evaluate(byte, model_completion=True).as_long()
            for byte in array
        ]

        print("[+] Flag bytes:")
        print(flag_bytes)

        print("[+] As characters:")
        flag_str = "".join(chr(b) if 32 <= b <= 126 else "." for b in flag_bytes)
        print(flag_str)

        return flag_str
    else:
        print("[-] No solution found.")
        return None

if __name__ == "__main__":
    conditions = parse_conditions_from_file("conditions.txt")
    solve_with_z3(conditions)


Office

ELF 1개가 주어진다. 실행 시 가상 Office에 출근하여 일당을 받거나, 일당 인상을 요구하거나, 사직할 수 있다.

image.png


Approach

실행 하였을 때 파악한 로직과 동일하게, 사용자에게 3가지 옵션이 주어진다.

void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  char s[3]; // [rsp+1h] [rbp-Fh] BYREF
  unsigned int v4; // [rsp+4h] [rbp-Ch]
  FILE *stream; // [rsp+8h] [rbp-8h]

  sub_40149A(a1, a2, a3);
  stream = fopen("/dev/urandom", "r");
  if ( !stream )
  {
    puts("Cannot open /dev/urandom");
    exit(1);
  }
  fread(&initial_balance, 1uLL, 1uLL, stream);
  fclose(stream);
  temp_balance = initial_balance;
  while ( 1 )
  {
    do
    {
      print_selections();
      fgets(s, 3, stdin);
      *__errno_location() = 0;
      v4 = strtol(s, 0LL, 10);
    }
    while ( *__errno_location() );
    if ( v4 == 3 )
      print_flag();
    if ( v4 > 3 )
      break;
    if ( v4 == 1 )
    {
      clock_in();
    }
    else
    {
      if ( v4 != 2 )
        break;
      raise();
    }
LABEL_14:
    if ( balance <= 0 )
    {
      puts("You can't even spend money and yet you lost it all. You're fired.");
      exit(0);
    }
  }
  printf("choice: %d\n", v4);
  goto LABEL_14;
}


1. clock_in 시 발생하는 이벤트가 완전 랜덤이 아닌, 잔고와 관련하여 연산된 값에 의존하여 발생한다. 참고로 byte_40408n 값들은 모두 특정 값이 정해져 있다.

__int64 clock_in()
{
  __int64 result; // rax

  if ( ((unsigned __int8)temp_balance & (unsigned __int8)byte_404088) != 0 )
    puts("You forget to put the cover sheet on your TPS report");
  if ( ((unsigned __int8)temp_balance & (unsigned __int8)byte_404089) != 0 )
    puts("You have a meeting with a consultant");
  if ( ((unsigned __int8)temp_balance & (unsigned __int8)byte_40408A) != 0 )
    puts("The printer jams");
  if ( ((unsigned __int8)temp_balance & (unsigned __int8)byte_40408B) != 0 )
    puts("Your boss tells you that you have to come in on Saturday");
  if ( ((unsigned __int8)temp_balance & (unsigned __int8)byte_40408C) != 0 )
    puts("The fire alarm goes off");
  if ( ((unsigned __int8)temp_balance & (unsigned __int8)byte_40408D) != 0 )
    puts("Your cowworker asks if you have seen his stapler");
  if ( ((unsigned __int8)temp_balance & (unsigned __int8)byte_40408E) != 0 )
    puts("You think about quitting");
  printf("Time to clock out. You made $%d today\n", (unsigned int)clock);
  balance += clock;
  result = balance ^ (unsigned int)(unsigned __int8)temp_balance;
  temp_balance ^= balance;
  return result;
}


3. quit job 선택 시 현재 잔고가 초기 잔고의 257배일 경우 플래그를 출력하는 히든 이벤트(?)가 있다. 그러므로 option 1과 2를 적절히 활용하여 balanceinitial_balance의 257배로 조정 후 option 3를 선택하면 되겠다. 유일한 문제는 initial_balancerandom하게 결정된다는 점이다.

void __noreturn print_flag()
{
  char ptr[56]; // [rsp+0h] [rbp-40h] BYREF
  FILE *stream; // [rsp+38h] [rbp-8h]

  if ( 257 * (unsigned __int8)initial_balance == balance )
  {
    stream = fopen("./flag.txt", "r");
    if ( !stream )
    {
      printf("Cannot open ./flag.txt");
      exit(1);
    }
    fread(ptr, 0x20uLL, 1uLL, stream);
    ptr[32] = 0;
    puts("You were actually nice to have around");
    puts("Here, take this parting gift:");
    puts(ptr);
    exit(0);
  }
  puts("Good riddance");
  exit(0);
}


PoC

Option 1에서 temp_balance와 XOR 연산한 결과에 따라 이벤트를 출력하므로, option 1을 선택할 때마다 현재 temp_balance의 조건을 알 수 있다. 반복적으로 option 1을 선택하면서, 이외 기타 연산을 적절히 처리하면 initial_balance를 알 수 있다. 이후엔 initial_balnce의 257배까지 남은 금액을 clock_in으로 요청하면 된다.

from pwn import *

def check_candidates(candidates,out):
    bitmask_table = {
        "cover sheet":     ("You forget to put the cover sheet", 0x0A),
        "consultant":      ("You have a meeting with a consultant", 0x16),
        "printer":         ("The printer jams", 0x18),
        "saturday":        ("Your boss tells you that you have to come in on Saturday", 0x28),
        "fire alarm":      ("The fire alarm goes off", 0xA8),
        "stapler":         ("Your cowworker asks if you have seen his stapler", 0x60),
        "quitting":        ("You think about quitting", 0x01),
    }
    
    for label, (msg, bit) in bitmask_table.items():
        if msg in out:
            candidates = [i for i in candidates if (i & bit) != 0]
        else:
            candidates = [i for i in candidates if (i & bit) == 0]
    
    return candidates

def xor_update(candidates,balance):
    candidates = [i^balance for i in candidates]
    return candidates

def calculate_goal(candidates, balance):
    candidate = candidates[0]
    while True:
        candidate ^=balance
        balance -=10
        if balance ==1337:
            break
    
    goal = 257 * candidate
    return goal

if __name__=='__main__':
    p = remote("office.kctf-453514-codelab.kctf.cloud", 1337)
    balance = 1337
    candidates = [i for i in range(256)]
    
    out = p.recvuntil(b"> ")
    print(out.decode(errors='ignore'))

    while True:
        print("-------------")
        print(candidates)
        if len(candidates)==1:
            break
        p.send(b"1\n")
        out = p.recvuntil(b"> ")
        print(out.decode(errors='ignore'))
        candidates = check_candidates(candidates,out.decode(errors='ignore'))
        balance += 10
        candidates = xor_update(candidates,balance)
        
    
    goal = calculate_goal(candidates,balance)
    print(balance)
    left = goal-balance
    
    print("[send 2]-----------------------")
    p.send(b"2\n")
    out = p.recvuntil(b"> ")
    print(out.decode(errors='ignore'))
    
    print(f"[send left {left}]-----------------------")
    p.sendline(bytes(str(left), "utf-8"))
    out = p.recvuntil(b"> ")
    print(out.decode(errors='ignore'))
    
    print("[send 1 again]-----------------------")
    p.send(b"1\n")
    out = p.recvuntil(b"> ")
    print(out.decode(errors='ignore'))
    
    print("[send 3]-----------------------")
    p.send(b"3\n")
    out = p.recvall(timeout=2)
    print(out.decode(errors='ignore'))