# Root Cause Analysis — PaperCut NG/MF 25.0.11 unauthenticated remote code execution (27 Aug 2026 advisory)

## Summary

Stock PaperCut NG/MF 25.0.11 (build 75758) allows a fully unauthenticated remote
attacker to execute operating-system commands as the PaperCut application-service
account (`papercut`). The chain combines two defects reachable through the
pre-authentication application surface of the running Application Server:

1. **Tapestry 3 "complex" direct-service authorization bypass.** Tapestry's
   `DirectService` accepts `service=direct/1/<RenderPage>/<ComponentPage>/<Component>`.
   It calls `validate()` (which fires PaperCut's `BasePaperCutPage.pageValidate`
   access-rights check) **only on the render page**, then triggers the listener on
   the *component page* without validating it. Using the public login page as
   render page (`Home`) and a privileged page as component page
   (`ConfigEditor`) yields unauthenticated arbitrary configuration writes
   (`quickFindForm` + `$Form`/`$Form$0` listeners invoke `ConfigManager.setString`).
2. **Unrestricted attacker-controlled SQL in the external Card/ID lookup.**
   `ExternalUserLookupDb.lookupUserByExternalCardNumber` opens a JDBC connection
   using the `user-lookup.db-driver` / `user-lookup.db-url` config keys and runs
   the `user-lookup.id-to-username-sql` template with the submitted card number
   bound as a parameter. With the bundled Apache Derby embedded driver the
   template may call `SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE`, giving an
   attacker-controlled arbitrary file write as the `papercut` user. The lookup is
   reached **before any login** through the web card/ID login flow
   (`auth.web-login.card-id.enable`) — `Home.login` →
   `AuthenticationManagerImpl.authenticateUserWithCard` →
   `UserManagerImpl.getUserByCardNumber` → `ExternalUserLookupManagerImpl` →
   `ExternalUserLookupDb`.

File write is escalated to in-JVM code execution by planting a new Tapestry page
(`WEB-INF/Pwn3.page` declaring `org.apache.tapestry.html.BasePage`, plus
`Pwn3.html` containing an OGNL expression binding) into the live Jetty webapp
extraction directory (`server/tmp/webapp-<build>/`), then requesting
`GET /app?service=page/Pwn3`. Tapestry loads the new page specification from the
servlet context on first access and evaluates the OGNL expression
(`@java.lang.Runtime@getRuntime().exec(...)`), executing the command in the
Application Server JVM. The command's output is written into the webroot and
fetched back over plain HTTP — a complete remote command receipt.

The emergency 25.0.12 (build 76497) patch blocks the chain at the SQL layer:
`ExternalUserLookupDb.createLookupSQLStatement` now rejects templates matching
`(?i)\b(CALL|EXEC|EXECUTE)\b|\bSYSCS_[A-Z0-9_]*\b` with
`ApplicationException("Unsafe external user lookup SQL blocked.")`.

## Impact

- Package/component: PaperCut NG (and MF) Application Server, all 25.0.x <= 25.0.11
  (and per the vendor advisory, effectively all supported branches until the
  emergency builds).
- Risk: critical. Unauthenticated remote OS command execution as the PaperCut
  service account from the web port (9191/9192), including from the internet if
  exposed. Post-exploitation runs as `pc-app` child processes, matching the
  vendor's IoCs.

## Impact Parity

- Disclosed/claimed maximum impact: unauthenticated remote code execution as the
  PaperCut application-service account via a pre-auth card/ID entrypoint.
- Reproduced impact from this run: exactly that — `uid=1001(papercut)
  gid=1001(papercut)` command output (`id; uname -a; cat /proc/1/comm`) executed
  inside the Application Server container and retrieved remotely over HTTP.
- Parity: **full**.
- Nothing claimed was left undemonstrated.

## Root Cause

Two chained defects:

1. `biz/papercut/pcng/web/pages/BasePaperCutPage.pageValidate` (25.0.11) only
   computes access rights for `this` — the page being validated. Tapestry 3's
   `DirectService.service()` validates only the *render* page
   (`cycle.activate(pageName)`) but then calls `componentPage.getNestedComponent
   (componentPath)` and `direct.trigger(cycle)` on a second page named later in
   the service path. `POST /app?service=direct/1/Home/ConfigEditor/$Form$0` (and
   `/quickFindForm`, `/$Form`) therefore fires ConfigEditor's form listeners
   (`doAddNew`, `doConfigEdit` → `ConfigManager.setString`) with no session at
   all. The 25.0.12 patch adds a loop over `service.split("/")` accumulating the
   rights of every embedded page — but note this check still only runs in
   `BasePaperCutPage.pageValidate`, and `Home` *overrides* `pageValidate`
   without calling `super`, so the config-write bypass via `direct/1/Home/...`
   in fact still lands on 25.0.12 (observed at runtime; the RCE chain is
   nonetheless blocked by the SQL filter below).
2. `biz/papercut/pcng/service/impl/ExternalUserLookupDb` built a
   `PreparedStatement` directly from the `user-lookup.id-to-username-sql` config
   value with no statement-type restriction, and opened the connection from
   config-controlled driver/URL (`DatabaseUtils.openConnection` →
   `Class.forName` + `DriverManager.getConnection`). Because the whole statement
   is attacker-controlled and the bundled Derby embedded driver supports
   `CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE(?, ...)`, the card number
   (bound to the `?`) becomes an arbitrary SQL query whose CLOB result is written
   verbatim to an arbitrary filesystem path. The advisory IoCs fall out of this
   naturally: failed attempts log `Database error looking up cardID: VALUES CAST...`
   and driver probing logs `No suitable driver found for jdbc:...`.

Fix commit: no public commit (commercial product). Vendor advisory:
`https://www.papercut.com/kb/Main/security-bulletin-27-aug-2026-urgent-security-advisory`
(emergency builds 25.0.12 build 76497 / 26.x; the FAQ documents the new
EXEC/EXECUTE/CALL restriction).

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (self-contained; requires docker + curl
   + python3; downloads the two official installers with vendor-published
   SHA-256 verification).
2. The script builds `pcng-vuln` (25.0.11.75758) and `pcng-fixed`
   (25.0.12.76497) images, starts both Application Servers, completes the stock
   setup wizard on each, performs one operator-side admin login + ConfigEditor
   render per server (models a normally operated server; warms Tapestry's pooled
   page/table state that the form-rewind path needs), and then runs the entire
   attack with **unauthenticated remote HTTP requests only**:
   - Phase A1: config write via `service=direct/1/Home/ConfigEditor/...`
     (arms `user-lookup.*` and `auth.web-login.card-id.*`).
   - Phase A2: two pre-auth card-login requests whose card number is
     `VALUES(CAST('<file content>' AS CLOB))`; the Derby export plants
     `WEB-INF/Pwn3.page` and `Pwn3.html` (OGNL payload) in the live webapp dir.
   - Phase A3: `GET /app?service=page/Pwn3` evaluates the OGNL payload.
   - Phase A4: `GET /pwn-proof.txt` retrieves the command output (remote
     receipt).
   - Phase B: identical request sequence against 25.0.12; verifies the armed
     lookup reaches the new filter (`Unsafe external user lookup SQL blocked`),
     no files are planted, no command receipt exists, and a benign non-CALL SQL
     template still executes (proving the feature works and only dangerous SQL
     is blocked).
3. Expected evidence: vuln proof file containing
   `PC0DAY-PROOF-BEGIN ... uid=1001(papercut) ... PC0DAY-PROOF-END` fetched over
   HTTP; fixed build logs the SQL block and returns 404 for the proof file.

Exit code 0 = chain proven on 25.0.11 and blocked on 25.0.12.

## Evidence

- Full run log: `bundle/logs/reproduction_steps.log` (two consecutive clean
  runs, both exit 0).
- Remote command receipt (fetched over HTTP): `bundle/artifacts/vuln-proof.txt`.
- Planted-page render response: `bundle/artifacts/vuln-render.html`.
- Vulnerable server.log IoC excerpts (attacker payload visible in
  `Database error looking up cardID: VALUES(CAST(...)`):
  `bundle/artifacts/vuln-serverlog-ioc.txt`.
- Fixed-build server.log excerpts showing
  `Unsafe external user lookup SQL blocked` plus the benign-SQL Derby error:
  `bundle/artifacts/fixed-serverlog-ioc.txt`.
- Per-request transcripts: `bundle/artifacts/*-wiz*.html(.headers)`,
  `bundle/artifacts/fixed-configwrite.html(.headers)`,
  `bundle/artifacts/vuln-proof.headers`, etc.
- Build identities: `bundle/artifacts/vuln-version.txt`,
  `bundle/artifacts/fixed-version.txt`; installer SHA-256s verified against the
  vendor advisory page (vuln 64495771…817a, fixed 0782c1d6…c392).
- Runtime manifest: `bundle/repro/runtime_manifest.json`.
- Patch localization (25.0.11 vs 25.0.12 class diff): `ExternalUserLookupDb`
  (SQL filter — the effective fix), `BasePaperCutPage` (service-path rights),
  `StandardRhinoContextFactory` (new Rhino ClassShutter blocking
  `java.lang.Class/ClassLoader/reflect/invoke`), `WebConfig` (path matching /
  response headers), `RestrictApiAccessFilter` (URI canonicalization).

## Recommendations / Next Steps

- Upgrade to the emergency build (25.0.12 / 26.0.3+) per the vendor advisory.
- The 25.0.12 fix blocks the RCE at the SQL layer, but the unauthenticated
  ConfigEditor write via Tapestry complex direct service was still observable on
  25.0.12 in this lab (Home overrides `pageValidate` without invoking the new
  service-path check). Recommend PaperCut route the rights check through a path
  that Home cannot skip (e.g., validate the component page in `DirectService`
  itself or move checks into a servlet filter), and audit every privileged page
  listener for the same pattern.
- Defense-in-depth: disallow `CALL`/`EXEC`/`SYSCS_` (already patched), consider
  allowing only plain `SELECT` templates; do not let `user-lookup.db-driver`
  accept arbitrary driver class names; disallow absolute paths in Derby export
  procedures via a Java SecurityManager-equivalent policy is no longer possible
  on JDK 21, so statement allow-listing is the right layer.
- Network-level: keep the Application Server web interface off untrusted
  networks (vendor's immediate guidance).

## Additional Notes

- Idempotency: the script was run end-to-end twice consecutively (fresh
  containers each time), both runs exited 0. Derby export procedures refuse to
  overwrite existing files, so each run uses fresh output paths
  (`/tmp/pc0day-a.csv`, `pc0day-b.csv`, unique per run) and fresh containers.
- Precondition: the Tapestry form-rewind path used for the config write relies
  on the pooled ConfigEditor page/table state, which exists after the Config
  Editor page has been rendered once since server start (any normal admin UI
  usage). The script performs this as an operator-side action (setup wizard
  admin account); it is not part of the attacker's request sequence. On a
  production server that has ever opened Options → Config Editor this
  precondition holds naturally.
- The card number is bound as a JDBC parameter (`?`), so no SQL-escaping of the
  payload is required; the entire export query rides in the card value. Content
  written to files must avoid single quotes or use Derby literal doubling; the
  OGNL payload sidesteps this by constructing strings from byte arrays.
- The print/device-script (Rhino) route was not needed; 25.0.12 additionally
  shutters Rhino reflection (`StandardRhinoContextFactory`), closing the
  alternate script-based escalation.
