MEMO #004 // EXPLOIT CHAIN DISPATCH CLEARANCE: LEVEL-4 // TOP SECRET UTC: --:--:--
SHORTCUT: [T] THEME / [ESC] BACK
ROOT / DISPATCHES / MEMO #004
DISPATCH // FULL WEAPONIZATION CHAIN CVSS 9.8 ROOT COMPROMISE

GrandScream: From One Desk Phone to a Root PBX

CHAIN PRIMITIVE Unauth Leak → Blind SQLi → Root
TARGET HARDWARE Grandstream GXP16xx / PBX
IMPACT SECTOR VoIP Hardware & Enterprise PBX
DISCLOSURE DATE 2026-08-15 UTC
RESEARCH OPERATIVE Akinlabi [Lulztigre]
[ABSTRACT & CORE THESIS]

One unauthenticated request against a Grandstream desk phone is enough to locate the PBX, fingerprint its firmware, and eventually own it, root shell included. This memo is the narrative of that chain: the leaks that pointed the way, the blind SQLi that gave up the password, and the credential reuse that collapsed three devices into one.

1. The Phone on the Reception Desk

The engagement started with one box: a Grandstream GXP1625 Executive IP Phone on 10.20.0.108, authorized lab, nothing special about it. A port scan of the thing is almost boring. Two open ports: 22, running dropbear SSH (2016.74), and 80, a web server fronting a /cgi-bin farm. No telnet, no debug ports, no pre-auth web RCE that applies to this firmware.

That last part mattered. I checked the phone for pre-auth exploitation first, because that is the reflex: the 2018 GXP16xx CVEs (17563/64/65) and the 2020 GXP1600 set (5738/39) are all patched in firmware 1.0.7.11. So the phone was never going to be the exploitation target. What it turned out to be was the best recon device on the network, and I almost missed it by looking for a bug instead of looking at the API.

2. The Phone Talks Too Much

The web server exposes a read API and a write API. The write side, api.values.post, is session-gated. The read side, api.values.get, is whitelist-filtered and needs no session at all. With an empty sid and a request of colon-joined keys it returns public values: phone model, firmware, lockout state, session timeout.

THE FIRST LEAK

GET /cgi-bin/api-get_accounts, no auth, returns the SIP account table: sip_server 10.20.0.4, sip_id 1000, name RECEPTION, registration 1. A desk phone just introduced me to its PBX.

That response changed the shape of the engagement. The phone tells you which host runs its telephony, unprompted, as plaintext. No sweep of the /22, no banner guessing: the dependency graph is handed over. Recon is a graph problem, and the phone to PBX edge arrived with a version of trust attached, the kind the chain abuses later.

Two more details mattered before I touched anything else. The login endpoint (/cgi-bin/dologin) returns different error codes for existing and unknown usernames: wrong1 means the user exists, wrong2 means it does not. Free username enumeration. And the lockout is aggressive, with a counter that persists across five-minute windows, so a guessing loop can lock the only device that matters. The script later checks /cgi-bin/api-get_lockout before every single password attempt. Recon is not just about the target's versions. It is about the target's defenses, before you risk triggering them.

3. The PBX, One Request Away

The sip_server field is 10.20.0.4. Fingerprinting it took one unauthenticated request: POST /cgi?action=getInfo over HTTPS 8089 returns the model, program version, and country. The answer: UCM6510, firmware 1.0.18.13 (base 1.0.18.2), hardware V1.4B, lighttpd/1.4.47 underneath, and a CTI service on TCP 8888.

That version is the go/no-go gate for the entire operation. 1.0.18.13 is below the 1.0.19.20 threshold, which puts the CVE-2020-572x family in scope: unauthenticated SQLi to RCE (5722, in CISA's Known Exploited Vulnerabilities catalog), password dump (5724/25/26), authenticated command injection (5757/58/59). On a patched box, that single getInfo response tells you to walk away in one request instead of after one failed exploit. Fingerprints are eligibility checks. Treat them that way.

4. An Oracle on Port 8888

The CTI service speaks a protocol that is almost aggressively simple. Four-byte big-endian length prefix, then a line like action=challenge&user=admin. The response is JSON, and it has exactly two interesting states: status 0 when the user matches, status -5 when it does not.

I sent a quote into the user parameter, and the response did what I wanted it to do, which is when a target becomes interesting. The user value lands in a SQLite query, and the binary status gives me a boolean oracle: 0 is true, -5 is false. That is CVE-2020-5726, a blind SQL injection in the CTI challenge, and it is the whole ballgame, because the challenge table holds the web login credential.

def oracle(self, cond, user="admin"):
    payload = f"action=challenge&user={user}' AND {cond}-- ".encode()
    r = self._send(payload)
    return r is not None and r.get("status") == 0

Then I hit the first real blocker of the engagement, and it is the kind that quietly kills blind injections. My first probes all returned false. Not injection-failed false. Everything false, consistently, which is the worst failure mode a blind injection can have, because it looks exactly like a patched target. The cause was one character: SQLite's -- comment requires a trailing space to be parsed. Without it, the query is a syntax error, the oracle answers false to everything, and the channel looks dead when it is merely malformed.

DIALECT QUIRKS ARE METHODOLOGY

The trailing space after -- is not decoration. It is the difference between a working oracle and a silent false negative. When a blind channel does not work, verify the payload against the dialect before declaring the target patched.

Extraction is plain bisection. Probe LENGTH() to find the length, then binary-search each character over the printable range 0x20 to 0x7E. The printable range spans 95 code points, so each character costs seven queries. A 12-character password plus the length loop lands around a hundred requests. Observed runtime in the lab: 13.2 seconds, single-threaded, polite connection churn included. The same extraction by hand in a proxy replays out to an hour of copy-paste and one silent typo.

5. Two Traps in Data You Cannot See

Blind extraction reads data you can never look at, and that distance breeds a specific kind of confidence. The users table taught me two lessons about it.

THE DECOY ROW

The users table contains an admin row whose password is not the web login password. The real credential lives in the challenge table, the one the outer query reads. A dump of the obvious table would have produced a working-looking but wrong answer. The script extracts with no FROM clause, reading the challenge query's own table directly.

The second trap is row order. Dumping the users table with LIMIT/OFFSET and no ORDER BY walks the index, while column pairs were verified against rowid order, and the two orders do not align. The blind full-table dump looked completely sane and was wrong: user_name and user_password shifted against each other. I only caught it because the chain later reached a root shell that gave ground truth from /cfg/etc/ucm_config.db. The rule I took from that: validate a blind extraction channel against one known value, and then never trust it blindly again.

6. Login, and What Admin Buys

With the password in hand, the web login is a formality. action=challenge returns a challenge string, token = md5(challenge + password), action=login with the token returns status 0 and a session-identify cookie. Attempts are limited to about five before the API starts answering with remain_num counters in status -37 responses, so the login code does not waste attempts.

Authenticated, the PBX admin API does what PBX admin APIs do. action=listAccount enumerates every extension. action=getSipAccount&extension=N returns that extension's SIP authid, secret, and voicemail PIN in plaintext. The users table dump gave 34 rows total, extension passwords in the clear. One compromised admin account is equivalent to every handset's SIP identity, because the API hands them out by design.

7. One Password, Three Hats

Then the credential reuse test, which is where the chain closes. In this lab, one password was simultaneously the UCM web admin password, the GXP phone web admin password, and extension 1000's SIP secret. That is not luck. It is how VoIP fleets get administered: one credential, deployed everywhere, because it is convenient.

The phone accepted it. POST /cgi-bin/dologin with the PBX's admin password returned success, a role, and an sid cookie. Full web admin on the phone, zero additional effort. The phone's SSH root rejected the same password, which is worth recording as a dead end: dropbear runs a separate authentication path and does not share the web credential store. The script treats harvested secrets as a priority queue (oracle password, then SIP secret, then user-supplied) and checks the lockout endpoint between attempts, bailing the moment the phone reports locked.

8. Root, and Ranking Paths by Reliability

Two roads to code execution existed on the PBX. The flashy one is CVE-2020-5722, the unauthenticated sendPasswordEmail SQLi with command injection, the one sitting in CISA KEV. It is also rate-limited to one call per 60 seconds, and on this firmware the injection point is wrapped in a quoted heredoc, so it may simply not fire.

The boring one is deterministic. SSH with the web admin password lands in a restricted dropbear CLI. From there:

UCM6500 > config
CONFIG > unset a;/bin/sh
~ # id

unset a;/bin/sh is CVE-2020-5759, a parameter injection in the restricted config sub-shell that drops straight into root busybox sh. It works every time, which is exactly why it is the final hop. Rank exploits by reliability, not by wow factor. The chain's last step is the unglamorous 2020 escape because it does not depend on a rate limit or a quoted heredoc behaving.

The root shell paid for itself immediately: sqlite3 /cfg/etc/ucm_config.db produced ground truth for every credential harvested blind, which is how the row-order bug in the dump got caught at all. Full admin on the phone, full admin on the PBX, root on the PBX, and every SIP secret in the fleet, in one chain.

9. The Script Is the Writeup That Executes

By hand, that chain is two browsers, a proxy tab, a spreadsheet of leaked fields, and an SSH session: roughly an hour, non-repeatable, and hostile to auditing. Automated, it is:

python grandscream.py --phone 10.20.0.108

[1] Phone fingerprint: 10.20.0.108 model=GXP1625 fw=1.0.7.11 sip_server=10.20.0.4 sip_id=1000 [2] PBX fingerprint: 10.20.0.4 model=UCM6510 version=1.0.18.13 [3] CTI blind SQLi (CVE-2020-5726) on 10.20.0.4:8888 [+] admin user_password (challenge table) = 'admin@123456' (13.2s) [4] UCM web login: [+] logged in role=admin [5] SIP authid=123456 secret= vmsecret= [6] Phone admin login: [+] (credential reuse)

Every step's output feeds the next step's input: the phone leaks the PBX, the oracle leaks the password, the password unlocks the harvest, the harvest feeds the reuse attempts. A chain with that shape wants to be a function pipeline. The design constraints that shaped the script are the lessons of the manual chain, encoded:

  • Stdlib only. socket, ssl, struct, urllib, hashlib, argparse. It drops onto any engagement box without pip; paramiko is optional and degrades to no interactive shell instead of crashing
  • Gated execution. The oracle is confirmed with a probe before any extraction starts; a patched box gets a clean not-vulnerable instead of garbage. Flags like --password skip the SQLi for post-auth re-runs
  • Quirks encoded once. The SQLite trailing space, the bisection bounds, the decoy table, the lockout pre-check, the 60-second rate limit on the web RCE path. The script is the writeup that executes; six months later it still remembers the dialect quirk you would not
  • Report-shaped output. The run() results dict maps one to one onto finding sections in the engagement report
  • Deliberately incomplete. The public build ships recon, oracle, login, harvest, and the SSH root path. The bind and reverse shell listeners and persistence helpers were stripped before publishing
AUTOMATION BOUNDARIES

Automating a chain does not automate judgment. The script will happily report injection not confirmed against a patched box, and that is a feature. Scope, authorization, and when to stop stay outside the code.

10. Notes for the Blue Team

  • Patch past 1.0.19.20 on UCM65xx, or question why a 2020 firmware is reachable from a user segment at all
  • Segment the voice VLAN. A reception desk phone should not be able to introduce an attacker to the PBX admin API
  • Kill credential reuse. Phone web admin, SIP secret, and PBX admin sharing one password is a single point of failure wearing three hats
  • Monitor the oracle signature: sustained status 0 / status -5 alternation on TCP 8888 from one source is not normal telephony behavior

Code and full command reference: github.com/Lulztigre/GrandScream. Python 3.8+, standard library only, paramiko optional for the shell hop. Authorized security testing only: every step above was developed and verified in a lab built for the purpose, and pointing this at a network you do not own is the fastest way to learn that the legal exploit chain is the shortest one.

CITE THIS RESEARCH DISPATCH
@article{lulz2026_grandscrea,
  author    = {Lulztigre},
  title     = {GrandScream: From One Desk Phone to a Root PBX},
  journal   = {Lulztigre Dispatches},
  year      = {2026},
  url       = {https://lulztigre.pw/posts/grandscream-phone-to-pbx-root.html}
}