Bi0s2024 babybs rev chal

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.

Overview

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

Ghidra load

Now that we know what we’re dealing with, let’s decompile it with ghidra.

“ghidra import options with real mode and base address 0x7c00”

as we can see, ghidra didn’t didn’t decompile it

“blank decompilation list”

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. bi0s flag bytes at the end

Analysis

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.

Debugging

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.

real mode segmentation method

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…

silt saying that compiling code for 12h is not an easy option

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"

what happens when i press a key?

Ok cool but why it is like this? Let’s think about the process. You click a button on your keyboard, what happens?

  1. scancode is send by a keyboard controller to motherboard, which sends it to CPU,
  2. OS receives the scancode and a keyboard driver (it can be e.g. USB or PS/2) translates it to a keycode,
  3. The keycode is send to Xserver which then sends XKeyPressedEvent to it’s client app,
  4. X client app then uses XLookupString which according to the selected keyboard layout, translates keycode to a keysymbol,

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.

Exploit

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 ;)