28.02.2024
This year has been exactly like the last year. I haven’t managed to solve any challenges, I managed to solve a rev chal after the end of the CTF.
Let’s look what we got here:
[crusom@crusomcarbon babybs]$ file babybs.bin
babybs.bin: DOS/MBR boot sector
But what is a boot sector?
Well, in the good ol’ days (before UEFI) BIOS knew if disc is bootable if it contained a “valid bootsector” signature bytes.
Offset | Size (bytes) | Description |
---|---|---|
0x000 | 440 | MBR Bootstrap (flat binary executable code) |
0x1B8 | 4 | Optional “Unique Disk ID / Signature”2 |
0x1BC | 2 | Optional, reserved 0x00003 |
0x1BE | 16 | First partition table entry |
0x1CE | 16 | Second partition table entry |
0x1DE | 16 | Third partition table entry |
0x1EE | 16 | Fourth partition table entry |
0x1FE | 2 (0x55, 0xAA) | “Valid bootsector” signature bytes |
MBR refers to the partitioning scheme (partition table), it has it’s drawbacks like 2Tb limit, there is also MBR’s succesor, GPT partitioning scheme.
But it’s not important for us, what we need to know is that BIOS traditionally loaded and executed first sector (if it contained valid signature byte) and here we’re given this sector.
Important informations:
What is real mode? We have 3 modes:
real mode is the simplest one, with no virtual memory or any memory protections.
Btw there’s also unreal mode which was a bug, but intel said that it’s a feature… So we consider it as a 4th mode.
Ok but… our CPUs are 32 or usually 64-bit these days, so why BIOS uses real mode??? Well, because backwards compatibility and stuff. Because of it, you still can boot DOS on your computer, which is rather cool :p
Now that we know what we’re dealing with, let’s decompile it with ghidra.
as we can see, ghidra didn’t didn’t decompile it
we see that first 4 bytes create int 0x13371337, and then there are 5 null bytes. Well it doesn’t seem like a code, so let’s try and decompile from 0x7c09 to 0x7c66 (later we see only null bytes).
Select the addresses and click d
It definetely is something!
But ghidra didn’t create function at 0x7c09, so let’s click f.
Looks good. Decompilation is not great, but we can see that it’s probably correct.
Now, if we scroll at the end, we can see something interesting.
Okay, we got only a handful of instructions, we don’t need decompilation, let’s just analyze it by hand.
0000:7c09 fa CLI ; clear interrupts flag
0000:7c0a 31 c0 XOR AX,AX
0000:7c0c 8e d8 MOV DS,AX
0000:7c0e 8e c0 MOV ES,AX
0000:7c10 8e d0 MOV SS,AX
0000:7c12 bc ff ff MOV SP,0xffff
0000:7c15 fb STI ; set interrupts flag
It’s a standard initialization procedure, nothing interesting, let’s go on.
LAB_0000_7c16 XREF[2]: 0000:7c2e(j),
FUN_0000_7c37:0000:7c47(j)
0000:7c16 e8 19 00 CALL FUN_0000_7c32 undefined FUN_0000_7c32()
0000:7c19 3c 1b CMP AL,0x1b
0000:7c1b 74 13 JZ LAB_0000_7c30
0000:7c1d 2c 30 SUB AL,0x30
0000:7c1f a2 08 7c MOV [DAT_0000_7c08],AL
0000:7c22 e8 12 00 CALL FUN_0000_7c37 undefined FUN_0000_7c37()
0000:7c25 66 a1 04 7c MOV EAX,[DAT_0000_7c04]
0000:7c29 66 3b 06 CMP EAX,dword ptr [DAT_0000_7c00] = 13371337h
00 7c
0000:7c2e 75 e6 JNZ LAB_0000_7c16
LAB_0000_7c30 XREF[2]: 0000:7c1b(j), 0000:7c30(j)
0000:7c30 eb fe JMP LAB_0000_7c30
let’s see what FUN_0000_7c32 does
**************************************************************
* FUNCTION *
**************************************************************
undefined __cdecl16near FUN_0000_7c32()
undefined AL:1 <RETURN>
FUN_0000_7c32 XREF[1]: main:0000:7c16(c)
0000:7c32 b4 00 MOV AH,0x0
0000:7c34 cd 16 INT 0x16
0000:7c36 c3 RET
Alright, it sets ah to 0 and does int 0x16. Number 0x16 specifies a 16th set of BIOS functions and ah specifies a function of number 0. This is a command “get keystroke”.
Return value is
AH = BIOS scan code
AL = ASCII character
Ok, let’s move on
LAB_0000_7c16 XREF[2]: 0000:7c2e(j),
FUN_0000_7c37:0000:7c47(j)
0000:7c16 e8 19 00 CALL get_key undefined get_key()
0000:7c19 3c 1b CMP AL,0x1b
0000:7c1b 74 13 JZ LAB_0000_7c30
0000:7c1d 2c 30 SUB AL,0x30
0000:7c1f a2 08 7c MOV [DAT_0000_7c08],AL
0000:7c22 e8 12 00 CALL FUN_0000_7c37 undefined FUN_0000_7c37()
0000:7c25 66 a1 04 7c MOV EAX,[DAT_0000_7c04]
0000:7c29 66 3b 06 CMP EAX,dword ptr [DAT_0000_7c00] = 13371337h
00 7c
0000:7c2e 75 e6 JNZ LAB_0000_7c16
LAB_0000_7c30 XREF[2]: 0000:7c1b(j), 0000:7c30(j)
0000:7c30 eb fe JMP LAB_0000_7c30
Then we check if al (ascii value) is 0x1b, if true, it jumps to 0x7c30 which is an infinite loop (it jumps to itself).
Otherwise we subtract 0x30 from al, save it at 0x7c08 at jump to 0x7c37, so let’s analyze it.
0000:7c37 b4 00 MOV AH,0x0
0000:7c39 cd 16 INT 0x16
0000:7c3b 80 fc 48 CMP AH,0x48
0000:7c3e 74 0b JZ LAB_0000_7c4b
0000:7c40 80 fc 50 CMP AH,0x50
0000:7c43 74 14 JZ LAB_0000_7c59
0000:7c45 3c 1c CMP AL,0x1c
0000:7c47 74 cd JZ LAB_0000_7c16
0000:7c49 eb ec JMP FUN_0000_7c37
It gets keystroke and compares ah with 0x48 which is ascii ‘H’, then jumps to LAB_0000_7c4b, otherwise compares with 0x50 which is ‘P’ and jumps to LAB_0000_7c59, otherwise compares al with 0x1c and if it’s true, we go back to the previous function, if not, we go back to the beginning of the current function.
LAB_0000_7c4b and LAB_0000_7c59 looks like this:
LAB_0000_7c4b XREF[1]: 0000:7c3e(j)
0000:7c4b 30 e4 XOR AH,AH
0000:7c4d a0 08 7c MOV AL,[0x7c08]
0000:7c50 05 04 7c ADD AX,0x7c04
0000:7c53 89 c3 MOV BX,AX
0000:7c55 80 07 01 ADD byte ptr [BX],0x1
0000:7c58 c3 RET
LAB_0000_7c59 XREF[1]: 0000:7c43(j)
0000:7c59 30 e4 XOR AH,AH
0000:7c5b a0 08 7c MOV AL,[0x7c08]
0000:7c5e 05 04 7c ADD AX,0x7c04
0000:7c61 89 c3 MOV BX,AX
0000:7c63 80 2f 01 SUB byte ptr [BX],0x1
0000:7c66 c3 RET
LAB_0000_7c4b takes the saved byte, adds 0x7c04 to it, and then adds 1 at the computed address. LAB_0000_7c59 does the same but subtracts 1.
After we return from this subroutine, we go back to our first function and continue execution
0000:7c25 66 a1 04 7c MOV EAX,[DAT_0000_7c04]
0000:7c29 66 3b 06 CMP EAX,dword ptr [DAT_0000_7c00] = 13371337h
00 7c
0000:7c2e 75 e6 JNZ LAB_0000_7c16
LAB_0000_7c30 XREF[2]: 0000:7c1b(j), 0000:7c30(j)
0000:7c30 eb fe JMP LAB_0000_7c30
We load what’s at 0x7c04 and compare it with 0x13371337, if it’s not equal, jump back at the beginning of this function, otherwise we halt.
Now that we know what the code does, let’s name some things.
0000:7c16 e8 19 00 CALL get_char
0000:7c19 3c 1b CMP AL,0x1b
0000:7c1b 74 13 JZ halt
0000:7c1d 2c 30 SUB AL,0x30
0000:7c1f a2 08 7c MOV [saved_char_addr],AL
0000:7c22 e8 12 00 CALL process_data
0000:7c25 66 a1 04 7c MOV EAX,[DAT_0000_7c04]
0000:7c29 66 3b 06 CMP EAX,dword ptr [DAT_0000_7c00] = 13371337h
00 7c
0000:7c2e 75 e6 JNZ LAB_0000_7c16
halt XREF[2]: 0000:7c1b(j), 0000:7c30(j)
0000:7c30 eb fe JMP halt
alright, we can increase or decrease any byte we want and we know where the flag is so our job it to just write a simple shellcode doing this.
Ok, the task is indeed simple, however i don’t know what byte should i send when reading a keystroke. Should it be just the value? Or maybe BIOS keycode? Or probably PS2 scan code?
The best way to check it, is to debug the code.
However real mode uses it’s own segmentation method, and qemu doesn’t support it, which is a problem.
I found 2 fixes for this.
First is patching qemu, the patch is simple and i believe that it is the best way to debug 16-bit code, however…
Yes i don’t have that much computing power.
The second one is gdb script method which works. Maybe not the best, there are still disassembly problems but works and allows me to debug the code.
Now, add ‘-s’ and ‘-S’ flags to qemu
qemu-system-i386 -nographic -drive file=babybs.bin,format=raw -s -S
‘-s’ launches gdbserver at port 1234, and ‘-S’ doesn’t start CPU at startup (so we can breakpoint).
Write a simple python script:
from pwn import *
r = process('./run.sh')
for _ in range(8):
r.recvline()
r.send(p8(0x20)) # scancode for letter d
asdf = input()
Run it and connect to gdbserver like this
gdb -ex "target remote localhost:1234" -ex "b *0x7c36" -ex "c"
AX: 3920 BX: 0000 CX: 0000 DX: 0080
SI: FF53 DI: 0000 SP: FFFD BP: 0000
CS: 0000 DS: 0000 ES: 0000 SS: 0000
IP: 7C36 EIP:00007C36
CS:IP: 0000:7C36 (0x07C36)
SS:SP: 0000:FFFD (0x0FFFD)
SS:BP: 0000:0000 (0x00000)
OF <0> DF <0> IF <1> TF <0> SF <0> ZF <1> AF <0> PF <1> CF <0>
ID <0> VIP <0> VIF <0> AC <0> VM <0> RF <0> NT <0> IOPL <0>
---------------------------[ CODE ]----
=> 0x7c36: ret
0x7c37: mov ah,0x0
0x7c39: int 0x16
0x7c3b: cmp ah,0x48
0x7c3e: je 0x7c4b
0x7c40: cmp ah,0x50
0x7c43: je 0x7c59
0x7c45: cmp al,0x1c
0x7c47: je 0x7c16
0x7c49: jmp 0x7c37
AL is 0x20 and AH is 0x39, it refers to space bar, not ‘d’. Ok so we know that what we send is in the AL (ascii value), and AH (scancode) is set accordingly to the keycodes table. In this case we should’ve sent 0x64.
Then we must set ah to 0x48 or 0x50, but it turns out we can’t just get ascii value in our table and use it. AH=0x48 refers to up arrow, which is not a normal keystroke, but an ansi escape sequence.
We can check a list of ansi escape sequence here and as we can see “cursor up” function is letter A.
The sequence structure is like this:
0x1B + "[" + <zero or more numbers, separated by ";"> + <a letter>
Okay then, we need to send
b"\x1b[A"
Ok cool but why it is like this? Let’s think about the process. You click a button on your keyboard, what happens?
This way we gain abstraction. For more elaborative explanation please check out this.
Now, qemu does reverse the process. It receives keycode, and translates it back to a scancode.
Btw you can use a tool showkey to check scancode and keycode of a key.
First thing, where we should put our shellcode? The most obvious choice is at 0x7c68, just after the code. However, it’s far. We should send 0x68-0x4+0x30 value but there’s no such ascii value in the table! I haven’t found a way to get 0x94 in AL. Maybe it’s some more complex sequence i don’t know about, idk.
Let’s just find a code cave somewhere close to 0x7c04. Well… There are 5 null bytes starting at 0x7c04! It’s not much but enough to print one character. We can just run our exploit with different offset every time and get the flag.
How do we jump to our shellcode then? Well, we can change the jmp instruction offset at 0x7c30 (remember? the infinite loop).
And how do we get to this jmp inst?
look at this:
0000:7c19 3c 1b CMP AL,0x1b
0000:7c1b 74 13 JZ halt
good.
One last thing, 7c08 is the place where the code saves our character. If we want to use it in the shellcode, it should be the last character send.
Let’s look at the exploit:
from pwn import *
for i in range(0,56):
context.terminal = ['st']
#r = process('./run.sh')
r = remote('13.201.224.182', 30696)
for _ in range(8):
r.recvline()
shellcode=asm(f"""
.code16
mov al, [{hex(0x7dc6 + i)}]
int 0x10
""")
def sendByte(to_send):
s = p8(to_send + 0x30)
r.send(s)
def sendDOWN():
r.send(b'\x1b[B') # high,low
def sendUP():
r.send(b'\x1b[A') # high,low
We connect to the server, make our 5 bytes shellcode printing a byte at 0x7dc6 + offset and define 3 functions we need.
One thing, we don’t set ah to 0xe in the shellcode as we should, because we’d have more than 5 bytes of instructions!
What we can do is send the last char having ah=0xe.
To have ah=0xe, we must send 0x08 (again, accordingly to the table), but then the condition
0000:7c19 3c 1b CMP AL,0x1b
won’t work… So we should change it to compare al with 0x08.
Ok, let’s continue.
from time import sleep
for i,c in enumerate(shellcode):
print(i)
if c > 127:
for _ in range(0xff-c+1):
sendByte(i)
time.sleep(0.07)
sendDOWN()
time.sleep(0.07)
else:
for _ in range(c):
sendByte(i)
time.sleep(0.07)
sendUP()
time.sleep(0.07)
We set bytes at our code cave to be the shellcode (sleep is needed, because otherwise keystrokes may not be processed in the right order).
# set jmp at 0x7c30
for i in range(0xfe-0xd2):
# 0x31 - 0x04 + 0x30
r.send(p8(0x5d))
time.sleep(0.07)
sendDOWN()
time.sleep(0.07)
i guess self explaining
# set cmp at 0x7c19 to cmp al, 0x8
for i in range(0x1b-8):
r.send(p8(0x46))
time.sleep(0.07)
sendDOWN()
time.sleep(0.07)
Here’s our cmp change.
# this will set last shellcode byte at 7c08
sendByte(shellcode[-1])
# this will set enter, cause we dont want to decrease or increase anything at this addr
r.send(p8(0x1c))
# and go to jump
r.send(p8(0x08))
byte = r.recv()
with open("flag", "a") as f:
f.write(byte.decode())
print(byte.decode())
# r.kill() # if we're using process() we should kill it, so it won't crash
You may wonder why do we write the shellcode at 0x7c04-0x7c08 and not after it??
Well, i just didn’t think about it. It’s a homework for the reader ;)