MEMO #004 STATUS: PUBLISHED NOTEBOOK
SHORTCUT: [T] THEME / [ESC] BACK
ROOT / DISPATCHES / MEMO #004

GrandScream: From One Desk Phone to a Root PBX

[ABSTRACT & CORE THESIS]

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.

[THE CHAIN, END TO END]

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.get with an empty sid returns 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_accounts returns the SIP account table: the SIP server IP, the extension's sip_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: wrong1 means the username exists, wrong2 means 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_lockout before 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)
[QUERY BUDGET FOR BISECTION]

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 users table holds an admin row 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/OFFSET without ORDER BY walks the index while column pairs were verified against rowid order, silently misaligning user_name with user_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 without pip, and paramiko is optional, feature-degrading to "no interactive shell" instead of crashing. The 25-line Http class (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 --password let you re-run post-auth stages without re-doing the SQLi, and --dump-users makes 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 a results dict — 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.
[REMARK ON AUTOMATION BOUNDARIES]

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 -5 alternation 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.

CITE THIS RESEARCH DISPATCH
@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}
}