infiltr8 Lab
Home/ CVE/ Web · Server/ Issabel Hard-Coded JWT Key Remote Code Execution
CVE · Web · Server · RCE · CVE-2026-89026
심층 분석 연구 분석

CVE-2026-89026 (Issabel Framework Hard-Coded JWT Key RCE) Analysis

Issabel, an open-source PBX (private branch exchange) platform, ships a REST API whose JWT signing key was a literal string baked into the source code and shared by every installation (CVSS 9.8). This post opens the vulnerable source and the patch commit directly to explain why perfectly ordinary-looking signature verification code still failed.

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.

infiltr8 team Original analysis #hardcoded-key#jwt-forgery#unauthenticated-rce#asterisk-ami
WhatThe JWT signing key used by Issabel's web API (pbxapi) was hard-coded into the source, letting an unauthenticated attacker reach remote code execution (RCE). CVSS 3.1 = 9.8.
HowThe code that verifies the JWT signature is fine. The problem is the secret key it verifies against — it is identical on every install and sits in plain text in the public source, so anyone can forge a valid token offline.
CoreOnce a forged token clears authentication, the manager controller's originate handler passes the application and data parameters straight through to the Asterisk AMI with no checks — set application=System and the server runs your OS command.

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 b97dbafAny 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.

function authorized($f3) { $headers = $f3->get('HEADERS'); if(!$f3->exists('HEADERS.Authorization')) { header($_SERVER['SERVER_PROTOCOL'] . ' 403 Unauthorized', true, 403); die(); } list (,$jwt) = preg_split("/ /",$headers['Authorization']); $key = $f3->get('JWT_KEY'); try { $data = JWT::decode($jwt, $key, array('HS256')); }catch(Exception $e) { /* … reject with 403 … */ }

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.

$f3->set('MGRPASS',$mgrpass); $f3->set('DB', new DB\SQL( 'mysql:host=localhost;port=3306;dbname=asterisk', 'root', $dbpass, $options)); $f3->set('JWT_KEY', 'da893kasdfam43k29akdkfaFFlsdfhj23rasdf'); $f3->set('JWT_EXPIRES', 60 * 60);

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.

case "originate": $channel = $f3->get('REQUEST.channel'); $extension = $f3->get('REQUEST.extension'); $context = $f3->get('REQUEST.context'); // … priority, timeout, callerid, variable, account follow the same pattern … $application = $f3->get('REQUEST.application'); $data = $f3->get('REQUEST.data'); $res = $this->ami->Originate($channel,$extension,$context,$priority,$timeout,$callerid,$variable,$account,$application,$data);

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.

Analysis verdict

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 공격 과정 요약

공격 흐름도
공격 흐름도 (직접 작성)

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 keyOffline JWT forgery (§8.2 step B) — signed with the literal key from public sourceA valid signature obtained without going through the login procedure at allClears the authentication check via the Authorization header
W1 — verification logic is sound, but its premise is brokenauthorize.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 uncheckedmanager::post()'s originate branch (§8.2 step E)application=System, data=<command> passed straight into the AMIAsterisk executes the System application → remote code execution

06취약점 패치

framework/html/pbxapi/index.php·Bootstrap (where JWT_KEY is set) + parse_conf()·commit b97dbaf0b71c1c36f841e672b664afbeb02773bd (2026-07-31, parent=beb0118)
if(is_file("/etc/issabel.conf")) { $data = parse_conf("/etc/issabel.conf"); $dbpass = $data['mysqlrootpwd']; $mgrpass = $data['amiadminpwd']; } if(!is_file("/etc/issabel.conf")) { throw new RuntimeException('/etc/issabel.conf not found'); } $data = parse_conf("/etc/issabel.conf"); $dbpass = $data['mysqlrootpwd']; $mgrpass = $data['amiadminpwd']; if(empty($data['pbxapijwtsecret'])) { throw new RuntimeException('Missing pbxapijwtsecret in /etc/issabel.conf'); } $jwtKey = base64_decode($data['pbxapijwtsecret'], true); if($jwtKey === false || strlen($jwtKey) < 32) { throw new RuntimeException('pbxapijwtsecret must contain at least 32 random bytes encoded as Base64'); } ( $options array · $f3->set('MGRPASS', …) · DB connection setup — unchanged, 11 lines omitted ) $f3->set('JWT_KEY', 'da893kasdfam43k29akdkfaFFlsdfhj23rasdf'); $f3->set('JWT_KEY', $jwtKey);

왜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.

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.