01개요
CVE-2026-89026 starts with a JWT (JSON Web Token — a self-verifying, signed token) signing key that Issabel Framework, the web framework behind the open-source PBX Issabel, hard-codes as a source-level constant for its REST API (pbxapi). Every installation ships the exact same value.
Because that key sat in plain text inside a public open-source repository rather than staying secret, an attacker needs no admin account and no password to mint their own valid authentication token signed with it. Feeding that forged token to the originate API — which is meant to place outbound calls on command — lets the attacker point one of its parameters at Asterisk's (the open-source telephony engine) System application, which runs an arbitrary shell command. The end result is unauthenticated OS command execution on the server.
CVSS: 9.8 (CVSS 3.1, vector AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) · CVSS 4.0 = 9.3
CWE-321 (Use of Hard-coded Cryptographic Key). No related CVE (standalone identifier). Exploitation observed in the wild by the Shadowserver Foundation starting 2026-09-09.
02영향받는 버전
| 대상 | 취약 버전 | 안전 버전 |
|---|---|---|
| Issabel Framework (pbxapi) | Everything before fix commit b97dbaf | Any build incorporating commit b97dbaf0b71c1c36f841e672b664afbeb02773bd (2026-07-31) or later |
03Background — F3 routing, JWT verification, and AMI Originate
Issabel Framework's REST API (pbxapi) sits on top of PHP's lightweight Fat-Free Framework (F3). Once index.php registers a routing rule like $f3->map('/@controller', '@controller'), an incoming URL such as /pbxapi/manager makes F3 auto-instantiate the PHP class matching the URL segment (manager) and call the method matching the HTTP verb (GET/POST/…).
Authentication is not handled per route — it happens in the controller's constructor. The manager controller extends the asmanager class, and that parent class's constructor creates an authorize object and calls authorized($f3) — meaning every request into this API is forced through this check the moment the controller object is built.
authorize::authorized() pulls the Bearer token out of the Authorization header and verifies its signature with the Firebase PHP-JWT library's JWT::decode(). A JWT has a header, a payload, and a signature; with HS256, the same secret key that the server alone is supposed to know both signs and verifies it — the entire premise of JWT authentication rests on that key being unknown to anyone else.
Once a request clears authentication, the manager controller talks to Asterisk's AMI (Asterisk Manager Interface) — a TCP-socket management protocol for querying and controlling calls. Its Originate action is normally "dial this extension and connect it to a given point in the dialplan," but the connection target can also be an Application parameter instead of an extension — and if that value is the dialplan's System() application, whatever string sits in the Data parameter runs as a shell command. That is legitimate Asterisk functionality, designed on the assumption that only a trusted administrator can call this action.
So the entire API's safety rests on exactly one thing — is the JWT signing key actually secret? The next section walks through how that premise broke.
04취약점 발생 원인
This vulnerability isn't the result of sloppy verification logic — it's that the material the verification relies on was public. Three weaknesses chain together: the verification logic (W1) is sound, but its premise (W2) is broken, and the execution path behind it (W3) is wide open once that premise fails.
1"A valid signature means it's safe" — the verification logic itself is sound
authorize::authorized()'s JWT signature check is correctly written, using a standard library. The problem isn't this function — it's the key it trusts.
models/authorize.php's authorized() rejects with a 403 immediately if there's no Authorization header, and otherwise verifies the signature with JWT::decode($jwt, $key, ['HS256']). Any exception (bad signature, expiry, etc.) also results in a 403. There is nothing wrong with this flow on its own.
IssabelFoundation/framework, actual source — models/authorize.php · authorized() (parent commit beb0118 — this file is unchanged by the fix commit, both before and after).
왜 이렇게 됐나Read in isolation, this is a textbook flow — reject unsigned requests, verify the signature, reject on failure. The one line highlighted ($f3->get('JWT_KEY')) is the sole weak point — where that value actually comes from is the real problem, covered next.
2The premise "this key is secret" is broken by a single line of source
The real value behind JWT_KEY is a string literal in index.php, and that literal is public to anyone through the open-source repository.
What $f3->get('JWT_KEY') in W1 actually returns is decided by the bootstrap code in index.php. Before the patch, this value was never read from a configuration file — it was a literal string written directly into the source.
IssabelFoundation/framework, actual source (pre-patch) — framework/html/pbxapi/index.php:47 (parent commit beb0118).
왜 이렇게 됐나The author appears to have left this as a placeholder meant to move into configuration later, but every installation shipped with the exact same value, and because the repository is public (GPLv3 open source), this single literal became a shared secret for every Issabel server in the world. This is precisely what CWE-321 (Use of Hard-coded Cryptographic Key) describes — not a hard-coded password (CWE-798), but a hard-coded key feeding an otherwise cryptographically sound signing procedure.
3"Once you're authenticated" the dangerous parameters are trusted outright
The manager controller's originate handler passes application and data straight through to the Asterisk AMI, with no filtering of any value.
A request that clears authentication (W1+W2) lands in the originate branch of manager::post(). It reads application, data, and the rest of the parameters directly from the request and forwards them unchecked to the Originate() call, regardless of value.
IssabelFoundation/framework, actual source — controllers/manager.php · originate branch inside post() (parent commit beb0118 — this file was not touched by the fix commit).
왜 이렇게 됐나The manager controller was designed as "an admin function only an already-authenticated PBX administrator will call," and on that premise it placed no allowlist or block on dangerous values like System/Shell for application·data. The moment W1/W2 give way, this generosity passes straight to the attacker.
The center of gravity is W2 (the hard-coded JWT signing key) alone.
W1 (signature verification) and W3 (parameter forwarding in originate) are each unremarkable, ordinary designs on their own — "trust a request once its signature is valid" and "trust an authenticated admin's request" are not bad assumptions by themselves. W2 turns that first assumption false, and with it, W3 — which was sitting behind the door with no further restriction — swings wide open too.
That's why the fix, too, is a single-file change to index.php at the point where the key is produced, addressing the root cause directly — not a single line in W1 or W3 was touched.
05공격 과정
5.1 공격 과정 요약
- The attacker reads the JWT signing key literal baked into
index.phpin the public IssabelFoundation/framework repository. - Using that key, they mint a validly HS256-signed Bearer token offline, without ever calling the login API (
POST /pbxapi/authenticate). - They send the forged token in an
Authorizationheader toPOST /pbxapi/manager/originate. authorize.phpverifies the signature and lets the request through — the verification logic wasn't fooled; the trust basis was simply public from the start.- The
managercontroller forwards the request'sapplicationanddataparameters, unchecked, straight into Asterisk AMI'sOriginateaction. - With
application=Systemspecified, Asterisk runs thedatavalue as a shell command — remote code execution, running as the asterisk process's user.
5.2 상세 공격 과정
This vulnerability differs from a typical "unvalidated input" case. The weak point isn't Originate itself — it's that the authentication gate in front of it turned out to be a door anyone could open. The attacker doesn't need to trick the server's verification logic the way a SQL injection or a parser bypass does. It's closer to already possessing the exact material the verification logic demands.
Once a forged token clears authorize.php, the request is indistinguishable from a legitimately logged-in PBX administrator's. From the manager controller's point of view, all it sees is "an authenticated administrator called originate" — it has no way to know whether that administrator is real or was manufactured from a forged signature.
Pointing Originate's call target at an Application instead of an extension (Extension/Context) is a legitimate, advanced Asterisk feature (used for IVR flows, automation scripts, and the like). The problem is that this API places no allowlist or dangerous-function block on the Application value — this is a conceptual description, and no working request payload is included in this post.
5.3 미흡점 ↔ 공격 흐름 매핑
| 미흡점(§4) | 발화 지점(§5.2) | 여는 프리미티브 | → 다음으로 잇는 것 |
|---|---|---|---|
| W2 — hard-coded JWT signing key | Offline JWT forgery (§8.2 step B) — signed with the literal key from public source | A valid signature obtained without going through the login procedure at all | Clears the authentication check via the Authorization header |
| W1 — verification logic is sound, but its premise is broken | authorize.php's JWT::decode() lets the request through (§8.2 step D) | The forged token is recognized as "a legitimate administrator session" | Access is granted into the manager controller's downstream logic |
| W3 — originate parameters forwarded unchecked | manager::post()'s originate branch (§8.2 step E) | application=System, data=<command> passed straight into the AMI | Asterisk executes the System application → remote code execution |
06취약점 패치
왜Because W2 (the hard-coded signing key) was the root cause. The verification logic (authorize.php) and the execution path (manager.php) were left untouched — the real fix is at the one point where trust originates: where the key itself is produced.
어떻게① If the configuration file /etc/issabel.conf is missing, the server now refuses to boot by throwing an exception (before the patch, a false is_file() silently left $mgrpass empty and the code kept running — a quietly dangerous default). ② A per-install value, pbxapijwtsecret, is read from that config file, Base64-decoded, and rejected if it decodes to fewer than 32 bytes — closing off the option of swapping in a short or guessable key. ③ The JWT_KEY hive value is finally set to this validated random key instead of the constant. As a side effect, parse_conf() was also hardened (skipping blank/comment lines, using explode(…, 2) so a value containing = still splits safely), reducing config-parsing edge cases.
비고The commit message — "Use jwt key stored in conf file instead of using a hardcoded one" — names the root cause exactly in one sentence.
07대응방안
The patch is effectively the only real fix. Update to any build that incorporates commit b97dbaf0b71c1c36f841e672b664afbeb02773bd (2026-07-31) or later. This repository carries no separate version tags or releases, so confirm through Issabel's own update channel that your build actually includes this commit.
- Until you can update, keep
/pbxapi— especially/pbxapi/manager/*— off the public internet and restrict access via VPN or IP allowlisting; this REST API appears to be intended for internal administrative use. - If an immediate update isn't possible, add a temporary rule on your reverse proxy or WAF to block
/pbxapi/manager/originaterequests whoseapplicationparameter is a command-executing value such asSystem,Shell, orAGI— keeping in mind that token verification alone will not catch this, since the signature itself is valid. - 탐지 관점. Watch your logs and WAF rules for requests to
/pbxapi/manager/originatecarrying anapplication=System(orShell/AGI) parameter, and for Bearer tokens being used without a preceding normal login (POST /pbxapi/authenticate) call.
08왜 이게 반복되는가
The core of this incident isn't "the authentication logic was sloppy" — it's that the ground authentication stood on (the signing key) was public from day one. JWT, HMAC, and every signature-based authentication scheme derives its safety not from the verification algorithm but from the secrecy of the key. Break that one premise, and no amount of correctly written verification code matters.
This shares its root with a social-login bypass case we've covered before ([[social-login-idp-bypass-variant-class]] family) — there, the server trusted an email claim instead of properly checking the identity provider's signature; here, the signature check itself was done correctly, but anyone could learn the key behind it. Different mechanism, same underlying structure: the final basis of an authentication decision was something an attacker could control or simply read.
This pattern is especially dangerous in open-source projects. In closed-source software a hard-coded key is hidden inside a binary and needs reverse engineering to recover; in open source, a single git clone exposes it outright. "A placeholder we meant to move into configuration" hardens into a permanent design flaw the moment the repository goes public — this case is a clean illustration of that.
Patch completeness assessment. ① Did it address the root cause: confirmed — the symmetric key was replaced with a per-install random value, and a minimum-32-byte check closes the door on short or predictable substitutes. ② Variant surface: inferred — index.php is the only place setting the JWT_KEY hive value, so the entire API, including the refresh-token path, is updated together by this fix; our static review found no separately hard-coded sibling key (we did not do a full history audit or dynamic confirmation, so this is a static signal only). ③ Silent or incomplete re-patch: inferred — parse_conf() was also hardened in the same commit, but that reads as incidental robustness rather than a separate vulnerability signal, and we found no later commit revisiting this fix. On balance, this patch reads as a root-cause fix, and this review did not turn up grounds to queue a separate patch-gap candidate.
The signature-verification code was flawless. The question was always what it was trusting to stay secret — so what we're recording here isn't a tool, but that question: what secrecy is this check actually resting on?
09결론
CVE-2026-89026 is a flaw in the open-source PBX Issabel's REST API where the JWT signing key was hard-coded into source and shared across every installation. The verification logic itself was sound, but because the root of that trust was public, a forged token could clear authentication and reach Asterisk AMI's System application through an unvalidated originate parameter — yielding remote code execution. The fix removes the root cause by replacing that key with a per-install random value (pbxapijwtsecret in /etc/issabel.conf).
참고 자료
The code excerpts in this post (index.php, models/authorize.php, models/asmanager.php, controllers/manager.php, models/asteriskmanager.php) are the actual source of the IssabelFoundation/framework open-source repository (GPLv3). We diffed the pre-patch code (the parent of the fix commit, beb0118, fetched raw via the GitHub API on 2026-09-17) against the post-patch code (fix commit b97dbaf, fetched the same way) line by line, and pulled the patch diff itself directly from the GitHub API's commit diff. The one link we did not trace to Asterisk's own source in this session is "why Application=System on the AMI Originate action executes an arbitrary OS command" — that part is reconstructed from public Asterisk AMI documentation and the vendor advisory's (VulnCheck) technical description, and we flag it here as secondary-source-based.
- NVD — CVE-2026-89026 detail · CVSS 3.1=9.8/4.0=9.3, CWE-321, official description (fetched 2026-09-17)
- IssabelFoundation/framework — fix commit b97dbaf · authoritative diff removing the hard-coded key in index.php (via GitHub API diff, fetched 2026-09-17)
- IssabelFoundation/framework open-source repository · vulnerable-version source (parent commit beb0118), diffed line by line (fetched 2026-09-17)
- VulnCheck — Issabel PBX Hard-coded JWT Key RCE Advisory · original technical analysis (NVD CNA), source of the Shadowserver observation date (2026-09-09)
- Cyber Security News — Critical Issabel PBX Command Execution Vulnerability Exploited in the Wild · independent cross-check (ITW status, recommendations)
- cflowsec/CVE-2026-89026 · public PoC repository (existence confirmed only — code not opened, per our harmlessness policy)