01개요
CVE-2026-89026은 오픈소스 PBX(사설 전화교환기) 소프트웨어 Issabel의 웹 프레임워크(Issabel Framework)가 자체 REST API(pbxapi)의 인증에 쓰는 JWT(JSON Web Token, 서명이 붙은 자가 검증형 토큰) 서명 키를 소스코드에 상수로 박아두고, 모든 설치본이 이 값을 그대로 쓴 데서 시작합니다.
이 키는 비밀이 아니라 공개 오픈소스 저장소 안에 평문으로 들어 있었기 때문에, 공격자는 관리자 계정도 비밀번호도 몰라도 그 키로 서명한 유효한 인증 토큰을 스스로 만들 수 있습니다. 이 위조 토큰으로 통화를 강제로 발신시키는 originate API를 호출하면, 그 파라미터 중 하나가 Asterisk(오픈소스 전화교환 엔진)에 임의 시스템 명령을 실행시키는 System 애플리케이션으로 이어져, 결과적으로 인증 없이 서버에서 OS 명령을 실행할 수 있게 됩니다.
CVSS: 9.8 (CVSS 3.1, 벡터 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) · CVSS 4.0 = 9.3
CWE-321(하드코딩된 암호화 키 사용). 관련 CVE 없음(단독 식별자). 실제 공격 관측 = Shadowserver Foundation, 2026-09-09.
02영향받는 버전
| 대상 | 취약 버전 | 안전 버전 |
|---|---|---|
| Issabel Framework (pbxapi) | 패치 커밋 b97dbaf 이전 전체 | 커밋 b97dbaf0b71c1c36f841e672b664afbeb02773bd(2026-07-31) 이후 반영 버전 |
03기술적 배경 — F3 라우팅, JWT 검증, 그리고 AMI Originate
Issabel Framework의 REST API(pbxapi)는 PHP의 경량 마이크로프레임워크인 Fat-Free Framework(F3) 위에 얹혀 있습니다. index.php가 $f3->map('/@controller', '@controller') 같은 라우팅 규칙을 등록해두면, /pbxapi/manager 같은 URL이 들어올 때 F3가 URL의 컨트롤러 이름과 같은 이름의 PHP 클래스(manager)를 자동으로 찾아 인스턴스화하고, HTTP 메서드(GET/POST/…)와 같은 이름의 메서드를 호출합니다.
인증은 개별 라우트가 아니라 컨트롤러의 생성자(constructor)에서 처리됩니다. manager 컨트롤러는 asmanager 클래스를 상속하는데, 이 부모 클래스의 생성자가 authorize 객체를 만들어 authorized($f3)를 호출합니다 — 즉 이 API로 들어오는 모든 요청은 컨트롤러가 만들어지는 순간 이 함수를 반드시 거칩니다.
authorize::authorized()는 Authorization 헤더에서 Bearer 토큰을 꺼내 Firebase PHP-JWT 라이브러리의 JWT::decode()로 서명을 검증합니다. JWT는 헤더·페이로드·서명 세 부분으로 이뤄져 있고, HS256 알고리즘에서는 서버만 아는 비밀 키 하나로 서명을 만들고 검증합니다 — 이 키를 모르면 유효한 서명을 만들 수 없다는 것이 JWT 인증 전체의 전제입니다.
인증을 통과하면 manager 컨트롤러는 Asterisk의 AMI(Asterisk Manager Interface) — TCP 소켓으로 통화 상태를 조회하거나 통화를 제어하는 관리 프로토콜 — 에 명령을 실어 보냅니다. AMI의 Originate 액션은 원래 "이 내선으로 전화를 걸어 다이얼플랜의 특정 지점으로 연결하라"는 명령인데, 연결 대상을 내선 대신 Application 파라미터로 지정할 수도 있습니다 — 이 값이 Asterisk 다이얼플랜의 System() 애플리케이션이면, Data 파라미터에 담긴 문자열을 셸 명령으로 그대로 실행합니다. 이건 Asterisk의 정상 기능이며, 신뢰된 관리자만 이 액션을 호출할 수 있다는 전제 위에 설계되어 있습니다.
여기까지 정리하면 이 API의 안전은 정확히 한 가지에 달려 있습니다 — JWT 서명 키가 정말 비밀인가. 다음 절에서 이 전제가 어떻게 깨졌는지 봅니다.
04취약점 발생 원인
이 취약점은 "검증 로직이 허술해서"가 아니라 "검증에 쓰는 재료가 공개돼서" 뚫립니다. 미흡점 세 개가 순서대로 맞물립니다 — 검증 로직(W1)은 정상이지만, 그 전제(W2)가 깨져 있고, 그 뒤에 있는 실행 경로(W3)가 아무 제약 없이 열려 있습니다.
1"서명을 확인하면 안전하다" — 검증 로직 자체는 정상이다
authorize::authorized()의 JWT 서명 검증 코드는 표준 라이브러리로 올바르게 짜여 있습니다. 문제는 이 절이 아니라 그 검증이 근거로 삼는 키에 있습니다.
models/authorize.php의 authorized()는 Authorization 헤더가 없으면 즉시 403으로 거부하고, 있으면 JWT::decode($jwt, $key, ['HS256'])로 서명을 검증합니다. 예외가 나면(서명 불일치·만료 등) 마찬가지로 403으로 거부합니다 — 이 흐름 자체엔 결함이 없습니다.
IssabelFoundation/framework 실제 소스 — models/authorize.php · authorized() (부모 커밋 beb0118, 패치 전과 후 동일 — 이 파일은 이번 커밋에서 변경되지 않았습니다).
왜 이렇게 됐나이 함수만 떼어 보면 "서명 없는 요청 거부 → 서명 검증 → 실패 시 거부"라는 정석적인 흐름입니다. 붉게 표시한 한 줄($f3->get('JWT_KEY'))이 유일한 약점입니다 — 이 값이 어디서 오는지가 다음 절의 진짜 문제입니다.
2"이 키는 비밀이다"라는 전제가 소스코드 한 줄로 깨진다
JWT_KEY의 실제 값이 index.php에 문자열 상수로 박혀 있고, 이 값이 오픈소스 저장소를 통해 모든 사람에게 공개돼 있습니다.
W1의 $f3->get('JWT_KEY')가 실제로 무엇을 돌려주는지는 index.php의 부트스트랩 코드에서 정해집니다. 패치 전 코드는 이 값을 설정 파일에서 읽지 않고, 소스코드에 그대로 적힌 문자열을 씁니다.
IssabelFoundation/framework 실제 소스(패치 전) — framework/html/pbxapi/index.php:47(부모 커밋 beb0118).
왜 이렇게 됐나작성자는 이 상수를 "나중에 설정으로 뺄 자리"의 임시값으로 남겨둔 것으로 보이지만, 모든 설치본이 같은 값을 그대로 배포받았고, 저장소가 공개(GPLv3 오픈소스)이므로 이 리터럴 하나가 곧 전 세계 모든 Issabel 서버의 공통 비밀이 됐습니다. CWE-321(하드코딩된 암호화 키 사용)이 정확히 이 패턴을 가리킵니다 — 하드코딩된 비밀번호(CWE-798)가 아니라, 암호학적으로는 정상인 서명 절차에 쓰이는 키 자체가 하드코딩된 경우입니다.
3"인증만 통과하면" 위험한 파라미터를 그대로 신뢰한다
manager 컨트롤러의 originate 처리는 application·data 파라미터에 어떤 값이 와도 걸러내지 않고 그대로 Asterisk AMI로 전달합니다.
인증(W1+W2)을 통과한 요청은 manager::post()의 originate 분기로 들어갑니다. 여기서 application과 data를 포함한 파라미터들을 요청 값 그대로 꺼내, 값이 무엇이든 검사 없이 Originate() 호출에 그대로 넘깁니다.
IssabelFoundation/framework 실제 소스 — controllers/manager.php · post() 내부 originate 분기(부모 커밋 beb0118, 이 파일은 패치 대상이 아니라 이번 커밋에서 변경되지 않았습니다).
왜 이렇게 됐나manager 컨트롤러는 originate를 "이미 인증된 PBX 관리자만 쓰는 관리 기능"으로 설계했고, 그 전제 위에서 application·data에 화이트리스트나 System/Shell 같은 위험 값 차단을 두지 않았습니다. W1·W2가 뚫리는 순간, 이 관대함이 그대로 공격자에게 상속됩니다.
무게 중심은 W2(JWT 서명 키의 하드코딩) 하나입니다.
W1(서명 검증 로직)과 W3(originate의 파라미터 전달)은 각각 떼어 놓고 보면 흔한 정상 설계입니다 — "서명이 유효하면 인증된 것으로 본다", "인증된 관리자의 요청은 신뢰한다"는 가정 자체는 잘못이 아닙니다. W2가 그 첫 번째 가정을 거짓으로 만들면서, 문 뒤에 아무 제약 없이 놓여 있던 W3까지 통째로 열립니다.
그래서 패치도 index.php 한 파일, JWT 키를 만드는 지점만 고치는 것으로 근본원인을 제거합니다 — W1·W3는 코드 한 줄도 바뀌지 않았습니다.
05공격 과정
5.1 공격 과정 요약
- 공격자가 공개된 IssabelFoundation/framework 저장소에서
index.php에 박힌 JWT 서명 키 리터럴을 확인합니다. - 이 키를 이용해 로그인 API(
POST /pbxapi/authenticate) 호출 없이, HS256으로 서명된 유효한 Bearer 토큰을 오프라인으로 직접 만듭니다. - 위조 토큰을
Authorization헤더에 담아POST /pbxapi/manager/originate를 호출합니다. authorize.php가 서명을 정상적으로 검증하고 통과시킵니다 — 검증 로직 자체는 속지 않았습니다, 다만 신뢰할 근거가 처음부터 공개돼 있었을 뿐입니다.manager컨트롤러가 요청의application·data파라미터를 검사 없이 그대로 Asterisk AMI의Originate액션에 실어 보냅니다.application=System으로 지정하면, Asterisk가data값을 셸 명령으로 그대로 실행합니다 — asterisk 프로세스 권한으로 원격 코드 실행이 성립합니다.
5.2 상세 공격 과정
이 취약점은 흔한 "입력값 미검증" 계열과 결이 다릅니다 — 취약한 지점은 Originate 자체가 아니라, 그 앞에 있어야 할 인증이 사실상 누구나 통과할 수 있는 문이었다는 데 있습니다. 공격자는 SQL 인젝션이나 파서 우회처럼 서버의 검증 로직을 속일 필요가 없습니다. 검증 로직이 요구하는 재료(서명 키)를 그냥 이미 갖고 있는 것과 같습니다.
위조한 토큰이 authorize.php를 통과하면, 이후로는 정상적으로 로그인한 PBX 관리자와 요청이 구분되지 않습니다. manager 컨트롤러 입장에서는 "인증된 관리자가 originate를 호출했다"는 사실만 보이고, 그 관리자가 진짜인지 위조된 서명으로 만들어졌는지는 알 방법이 없습니다.
Originate 액션에서 통화를 연결할 목적지를 내선(Extension/Context) 대신 Application으로 지정하는 것은 Asterisk의 정상적인 고급 기능입니다(IVR·자동화 스크립트 등에 쓰입니다). 문제는 이 API가 Application 값에 허용 목록이나 위험 함수 차단을 두지 않았다는 점이며, 이는 개념 설명이고 실제 작동하는 요청 페이로드는 이 글에 싣지 않습니다.
5.3 미흡점 ↔ 공격 흐름 매핑
| 미흡점(§4) | 발화 지점(§5.2) | 여는 프리미티브 | → 다음으로 잇는 것 |
|---|---|---|---|
| W2 — JWT 서명 키 하드코딩 | 오프라인 JWT 위조(§8.2 B단계) — 공개 소스의 리터럴 키로 서명 | 인증 절차(로그인 API) 없이 유효 서명 획득 | Authorization 헤더로 인증 검증 단계 통과 |
| W1 — 검증 로직은 정상이나 전제가 깨짐 | authorize.php의 JWT::decode() 통과(§8.2 D단계) | 위조 토큰이 "정당한 관리자 세션"으로 인정됨 | manager 컨트롤러의 후속 로직 진입 허용 |
| W3 — originate 파라미터 무검증 전달 | manager::post() originate 분기(§8.2 E단계) | application=System, data=<명령>이 그대로 AMI에 전달 | Asterisk가 System 애플리케이션 실행 → 원격 코드 실행 |
06취약점 패치
왜W2(서명 키 하드코딩)가 근본원인이었기 때문입니다. 검증 로직(authorize.php)과 실행 경로(manager.php)는 그대로 두고, 신뢰가 흘러나오는 첫 지점 — 키가 만들어지는 곳 — 을 고치는 것이 근본적인 수정입니다.
어떻게① 설정 파일 /etc/issabel.conf가 없으면 애초에 서버를 부팅하지 못하게 예외를 던지고(패치 전엔 is_file()이 거짓이면 $mgrpass가 그냥 비어버리는, 조용히 위험한 기본값으로 흘렀습니다), ② 설치별로 다른 값인 pbxapijwtsecret을 그 설정 파일에서 읽어 Base64로 디코딩하고 32바이트 미만이면 거부합니다 — 짧거나 예측 가능한 키로 교체하는 회피 여지까지 막았습니다. ③ 최종적으로 JWT_KEY hive 값에 상수 대신 이 검증된 랜덤 키를 넣습니다. 부수적으로 parse_conf()도 견고화되어(빈 줄·주석 줄 skip, explode(…, 2)로 값에 =가 들어가도 안전하게 분리) 설정 파일 파싱 오류 여지를 줄였습니다.
비고커밋 메시지는 "Use jwt key stored in conf file instead of using a hardcoded one" — 원인을 정확히 한 문장으로 짚고 있습니다.
07대응방안
패치가 사실상 유일한 근본 해결책입니다. 커밋 b97dbaf0b71c1c36f841e672b664afbeb02773bd(2026-07-31) 이후 반영된 코드로 업데이트합니다. 이 저장소는 별도의 버전 태그/릴리스를 두지 않으므로, Issabel 자체 업데이트 채널을 통해 이 커밋이 포함된 빌드로 갱신됐는지 확인해야 합니다.
- 업데이트 전까지는
/pbxapi, 특히/pbxapi/manager/*엔드포인트를 공인 인터넷에 노출하지 말고 VPN·IP 허용목록으로 접근을 제한합니다 — 이 REST API는 설계상 내부 관리용으로 보입니다. - 즉시 업데이트가 불가능한 환경이라면, 리버스 프록시나 WAF에서
/pbxapi/manager/originate요청 중application파라미터가System·Shell·AGI등 명령 실행형 값인 요청을 임시로 차단하는 규칙을 둡니다(서명 자체는 유효하므로 토큰 검사만으로는 걸러지지 않는다는 점에 주의합니다). - 탐지 관점.
/pbxapi/manager/originate로 향하는 요청 중application=System(또는Shell/AGI) 파라미터를 포함하는 요청, 그리고 정상적인 로그인 절차(POST /pbxapi/authenticate) 없이 곧바로 Bearer 토큰이 쓰이기 시작하는 패턴을 로그·WAF 규칙에서 관찰합니다.
08왜 이게 반복되는가
이 사고의 핵심은 "인증 로직이 허술했다"가 아니라 "인증이 서 있는 땅(서명 키)이 처음부터 공개돼 있었다"는 것입니다. JWT·HMAC·서명 기반 인증 전부는 검증 알고리즘이 아니라 키의 비밀성에 안전성이 걸려 있습니다 — 이 전제 하나가 무너지면 아무리 정교한 검증 코드도 무의미해집니다.
이건 저희가 이전에 살펴본 소셜 로그인 우회 사례([[social-login-idp-bypass-variant-class]] 계열)와 뿌리가 같습니다 — 그때는 서버가 신원 제공자(IdP)의 서명을 제대로 확인하지 않고 이메일 클레임만 믿었다면, 이번엔 서명 확인 자체는 제대로 하되 그 서명의 근거(키)를 아무나 알 수 있었다는 차이만 있을 뿐, "인증 판단의 최종 근거를 공격자가 통제하거나 열람할 수 있다"는 구조는 동일합니다.
오픈소스 프로젝트에서 이 패턴은 특히 위험합니다 — 클로즈드소스라면 키가 바이너리 안에 숨어 있어 리버싱이 필요하지만, 오픈소스는 git clone 한 번이면 하드코딩된 비밀이 그대로 드러납니다. "설정으로 뺄 계획이었던 임시값"이 저장소가 공개되는 순간 영구적인 설계 결함으로 굳어진다는 점을 이 사례가 잘 보여줍니다.
패치 완전성 평가. ① 근본원인을 막았나: 확인 — 대칭키를 설치별 랜덤 값으로 교체하고, 최소 32바이트 검증까지 추가해 짧은/예측 가능한 키로의 회피 여지도 막았습니다. ② variant 여지: 분석 — JWT_KEY hive 값을 설정하는 지점은 index.php 한 곳뿐이라 refresh_token 발급 경로를 포함한 API 전체가 이번 패치로 함께 갱신되며, 정적 검토 범위에서 별도로 하드코딩된 형제 키는 확인되지 않았습니다(전체 히스토리 전수조사·동적 확증은 하지 않은 정적 신호일 뿐입니다). ③ silent/불완전 재패치: 분석 — 같은 커밋에서 parse_conf()도 함께 견고화됐지만 별도 취약점 신호로는 보이지 않고, 이 커밋을 되짚는 후속 재패치 이력도 확인되지 않았습니다. 종합하면 이번 패치는 근본원인 제거로 판단되며, 별도 patch-gap 후보로 큐잉할 만한 신호는 이번 검토에서 나오지 않았습니다.
서명 검증 코드는 완벽했습니다. 문제는 그 코드가 무엇을 비밀로 믿었는가였습니다 — 그래서 저희는 도구가 아니라 이 질문("이 검증은 무엇의 비밀성에 기대고 있나")을 남깁니다.
09결론
CVE-2026-89026은 오픈소스 PBX Issabel의 REST API가 쓰는 JWT 서명 키가 소스코드에 하드코딩돼 전 설치본이 공유한 결함입니다. 검증 로직 자체는 정상이었지만, 그 신뢰의 뿌리가 공개돼 있었기에 위조 토큰으로 인증을 통과하고, 검증되지 않은 originate 파라미터를 통해 Asterisk AMI의 System 애플리케이션까지 도달해 원격 코드 실행이 성립했습니다. 패치는 이 키를 설치별 랜덤 값(/etc/issabel.conf의 pbxapijwtsecret)으로 교체하는 것으로 근본원인을 제거합니다.
참고 자료
이 글의 코드 발췌(index.php, models/authorize.php, models/asmanager.php, controllers/manager.php, models/asteriskmanager.php)는 IssabelFoundation/framework 오픈소스(GPLv3) 저장소의 실제 소스입니다. 취약 버전 코드는 패치 커밋의 부모 커밋(beb0118, GitHub API로 2026-09-17 직접 raw fetch), 패치 후 코드는 패치 커밋 b97dbaf(동일 방식으로 raw fetch)를 기준으로 라인 단위로 직접 대조했습니다. 패치 diff도 GitHub API의 커밋 diff를 그대로 받아 확인했습니다. 다만 "Asterisk AMI의 Originate 액션에서 Application=System이 실제로 임의 OS 명령을 실행한다"는 부분은 Asterisk 자체 소스코드까지 이 세션에서 직접 추적하지 않았고, 공개된 Asterisk AMI 문서와 벤더 어드바이저리(VulnCheck)의 기술 설명을 근거로 재구성했습니다 — 그 연결 부분은 2차 출처 기반임을 밝힙니다.
- NVD — CVE-2026-89026 상세 · CVSS 3.1=9.8/4.0=9.3, CWE-321, 공식 설명 (fetch 2026-09-17)
- IssabelFoundation/framework — 패치 커밋 b97dbaf · index.php 하드코딩 키 제거 diff 정본 (GitHub API diff, fetch 2026-09-17)
- IssabelFoundation/framework 오픈소스 저장소 · 취약 버전 소스(부모 커밋 beb0118) 라인별 직접 대조 (fetch 2026-09-17)
- VulnCheck — Issabel PBX Hard-coded JWT Key RCE Advisory · 원 기술분석(NVD CNA), Shadowserver 관측일(2026-09-09) 근거
- Cyber Security News — Critical Issabel PBX Command Execution Vulnerability Exploited in the Wild · 독립 교차확인(ITW·권고사항)
- cflowsec/CVE-2026-89026 · 공개 PoC 저장소(존재 확인만, 코드 미열람 — 무해화 원칙)