Switchvox CVE-2026-9586 認証の外側に置かれた1本のエンドポイント

IP-PBX の Sangoma Switchvox に未認証の SQL インジェクションがある。CVE-2026-9586 で、CVSS 3.1 は 9.8(Critical)、CISA の KEV カタログには 2026年9月2日に収載され、是正期限は 2026年9月5日だった。

この件で読み解く価値があるのは、欠陥そのものよりも時系列である。NVD の公開は 2026年7月17日、ベンダの修正版 8.4.0.2 のリリースノートは 2026年7月14日付。修正は7月に出ていて、悪用が確認されたのが9月という順序になっている。「見たときは静かだった」という理由で後回しにした環境が、いちばん危ない位置にいる。

この記事では、NVD の記述から欠陥の形を追い、手元の Python で連結とパラメータ化の差を実際に走らせて確かめ、最後にアクセスログの絞り込みスクリプトを置く。攻撃コードは書かない。扱うのは欠陥の形と、自分の環境を確かめる手順である。

NVD が言っていること

まず原文を見る。

An unauthenticated SQL injection vulnerability exists in Sangoma Switchvox SMB Edition 8.3 (104997). The /pa endpoint processes XML content beginning with <PolycomIPPhone> and directly concatenates the user-controlled PhoneIP value into PostgreSQL queries without sanitization or parameterization. An unauthenticated remote attacker can execute arbitrary SQL statements against the backend PostgreSQL database using a single crafted request, including database operations and remote code execution.

日本語にすると、/pa エンドポイントは <PolycomIPPhone> で始まる XML を処理し、利用者が制御できる PhoneIP の値を、無害化もパラメータ化もせずに PostgreSQL のクエリへ直接連結している。未認証のリモート攻撃者は、細工した単一のリクエストで任意の SQL 文を実行でき、データベース操作およびリモートコード実行を含む、という内容である。

CWE は CWE-89(SQLインジェクション)。ベクトルは CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H で、権限も利用者の操作も要らず、ネットワークから届けば成立するという最も重い組み合わせになっている。

なぜ「未認証」なのか

「PBX の管理画面なんだから認証があるだろう」と思うところだが、/pa は管理画面ではない。IP 電話機がプロビジョニング情報を取りに来るための口である。

ここに構造的な事情がある。電話機は資格情報を持っていない。 机の上に置かれた VVX なり何なりは、電源を入れた直後、まだ自分が誰なのかを知らない。だから「自分の IP はこれです、設定をください」と名乗り出る先が要る。その口に認証を掛けると、認証情報を配るために認証が要るという循環になる。実装上の答えは、たいてい「その口だけ認証の外に置く」になる。

つまり /pa が未認証なのは事故ではなく設計である。問題は、認証の外側に置いた口の中で、受け取った値を SQL に連結していたことである。攻撃者は電話機のふりをする必要すらない。XML を1本投げるだけで、そこに書いた文字列が SQL の一部になる。

NVD が remote code execution まで書いているのは、PostgreSQL 上で実行できる操作が足がかりになるためである。SQL の実行で止まらない。

連結とパラメータ化の差を手元で見る

「値をそのまま連結する」が何を許すのかは、DB エンジンに依らない。実際の欠陥は PostgreSQL 上のものだが、形を確かめるだけなら標準ライブラリの sqlite3 で足りる。次のスクリプトは実際に走らせて出力まで確認したものである。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/usr/bin/env python3
"""CVE-2026-9586 と同じ形の «文字列連結によるSQL» と «パラメータ化» の差を、
手元の sqlite3 で確かめる。実際の欠陥は PostgreSQL 上だが、
連結が何を許すかは DB エンジンに依らない。"""
import sqlite3

def setup():
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE phones (id INTEGER PRIMARY KEY, phone_ip TEXT, ext TEXT);
INSERT INTO phones (phone_ip, ext) VALUES ('10.0.0.11', '1001');
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, password_hash TEXT);
INSERT INTO users (name, password_hash) VALUES ('admin', 'e3b0c442...');
""")
return db

def lookup_concat(db, phone_ip):
# webui 側と同じ形: 値をそのままクエリ文字列に埋める
sql = "SELECT ext FROM phones WHERE phone_ip = '%s'" % phone_ip
return sql, db.execute(sql).fetchall()

def lookup_param(db, phone_ip):
sql = "SELECT ext FROM phones WHERE phone_ip = ?"
return sql, db.execute(sql, (phone_ip,)).fetchall()

BENIGN = "10.0.0.11"
CRAFTED = "10.0.0.11' UNION SELECT name FROM users --"

db = setup()
for label, fn in (("連結", lookup_concat), ("パラメータ化", lookup_param)):
for kind, value in (("通常", BENIGN), ("細工", CRAFTED)):
try:
sql, rows = fn(db, value)
print("[%s/%s] rows=%r" % (label, kind, [r[0] for r in rows]))
except sqlite3.Error as e:
print("[%s/%s] error=%s" % (label, kind, e))

出力はこうなる。

1
2
3
4
[連結/通常] rows=['1001']
[連結/細工] rows=['1001', 'admin']
[パラメータ化/通常] rows=['1001']
[パラメータ化/細工] rows=[]

見どころは [パラメータ化/細工] が例外を投げていないところである。エラーにならず、単に0件が返る。パラメータ化されたクエリでは、渡した文字列は最後まで「値」であって、SQL の構造には決してならない。10.0.0.11' UNION SELECT ... というそういう名前の phone_ip を探しに行って、無かった。それだけである。

連結の側は逆に、' で文字列リテラルを閉じ、UNION SELECT を足し、-- で残りをコメントにしている。内線番号を引くはずのクエリが、users テーブルを読むクエリに化けた。 これが /pa で起きていたことの縮図である。

「入力を検証すればいい」ではなく「そもそも値を構造にしない」が対処である、というのがこの出力の意味するところになる。

対象と修正版

NVD の適用範囲は cpe:2.3:a:sangoma:switchvoxon-premises 版、8.2.2.1 以上 8.4.0.2 未満。修正版は 8.4.0.2 で、Sangoma のリリースノートは 2026年7月14日付である。

8.2.2.1 より前の版は NVD の範囲外だが、それは「安全」という意味ではない。 照合の対象外というだけである。古い版を使っているなら、同じく最新へ上げる判断になる。

参照として NVD に載っているのは次の3本。

  • Sangoma のリリースノート(Switchvox 8.4.0.2 / 2026年7月14日)
  • SRA Labs の記事 labs.sra.io/posts/switchvox/
  • Horizon3.ai の開示 horizon3.ai/attack-research/disclosures/cve-2026-9586-sangoma-switchvox-rce/

悪用の実証が公開されているということは、更新までの猶予をそこに期待できないという意味でもある。

更新できない期間に何をするか

現実には「電話が止まる作業は今夜はできない」という事情がある。その場合の順序を書いておく。

1. /pa に到達できる範囲を絞る。 これが本筋である。プロビジョニングの口は社内の電話機セグメントからしか届かないのが本来の姿で、インターネットから Switchvox の Web 面が見えているなら、まずそこを閉じる。前段のファイアウォールで送信元を電話機のサブネットに限定する、あるいは VPN 越しに限定する。

在宅の内線など「電話機が外から繋がる必要がある」構成でも、/pa を無制限に開ける理由にはならない。SIP のシグナリングとプロビジョニングは別の口であり、後者だけを絞ることはできる。

2. アクセスログを見る。 後述のスクリプトを使う。

3. 通話転送と外線発信の設定を確認する。 これは PBX 固有の話で、忘れやすい。Web サーバの侵害なら見るのはデータの持ち出しだが、PBX の侵害は国際電話の不正発信(トールフラウド)による直接の金銭被害につながる。侵害の可能性を検討する段階で、転送先と発信権限の設定が書き換わっていないかを必ず見る。請求が来てから気づくと、金額が桁で効いてくる。

アクセスログの絞り込み

標準入力にログを流し、/pa へのリクエストのうち SQL のメタ文字を含むものだけを出すスクリプトである。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/bin/env python3
"""Switchvox の /pa へのリクエストを、Web アクセスログから拾って絞り込む。
標準入力にログを流す。SQL のメタ文字を含むものだけを「要確認」として出す。"""
import re, sys

REQ = re.compile(r'"(?P<method>[A-Z]+)\s+(?P<path>[^ "]+)[^"]*"\s+(?P<status>\d{3})\s+(?P<bytes>\d+|-)')
IP = re.compile(r'^(?P<ip>\S+)')
SUSPECT = re.compile(r"""(?ix)
(%27|') # 引用符(生/URLエンコード)
| (%3B|;) # 文の区切り
| \b(union|select|insert|update|delete|pg_sleep|pg_read_file)\b
| (--|/\*) # コメント
""")

total = hits = flagged = 0
for line in sys.stdin:
total += 1
m = REQ.search(line)
if not m or "/pa" not in m.group("path"):
continue
hits += 1
if SUSPECT.search(m.group("path")):
flagged += 1
ip = IP.match(line).group("ip")
print("SUSPECT %s %s %s %s" % (ip, m.group("method"), m.group("status"), m.group("path")))

print("---", "総行数 %d / /pa %d / 要確認 %d" % (total, hits, flagged), file=sys.stderr)

サンプルログに対して実行すると、次のようになる。

1
2
3
4
$ python3 scan_pa.py < sample.log
--- 総行数 5 / /pa 4 / 要確認 2
SUSPECT 203.0.113.9 POST 200 /pa?PhoneIP=10.0.0.1%27%20UNION%20SELECT%20version()--
SUSPECT 203.0.113.9 POST 500 /pa;id

このスクリプトの限界を先に書いておく。 実際の攻撃は PhoneIPPOST の本文(XML)に載せる。標準的な combined 形式のアクセスログは本文を記録しないので、本文に仕込まれた分はここには出てこない。つまり 0件でも「攻撃されていない」証拠にはならない

それでも走らせる価値があるのは、次の2つが読めるからである。

  • /pa に来ている送信元 IP の一覧。電話機のサブネット以外から来ていれば、その時点で構成の問題である(上の例では 203.0.113.9)。
  • 雑な試行。クエリ文字列に載せてくる粗い走査は、この形で引っかかる。

本文まで見たいなら、リバースプロキシ側で /pa に限って本文をログに残す設定を入れるか、PostgreSQL の log_statement を有効にして想定外のテーブルへの参照を探すほうが確実である。後者は「アプリが投げたクエリ」を直接見るので、経路に依らない。

sample.log は次の内容である。手元で試すときはこれをコピーして使える。

1
2
3
4
5
10.0.0.11 - - [08/Sep/2026:09:12:03 +0900] "POST /pa HTTP/1.1" 200 512 "-" "PolycomVVX/6.4.0"
10.0.0.12 - - [08/Sep/2026:09:12:44 +0900] "POST /pa HTTP/1.1" 200 512 "-" "PolycomVVX/6.4.0"
203.0.113.9 - - [08/Sep/2026:09:20:11 +0900] "POST /pa?PhoneIP=10.0.0.1%27%20UNION%20SELECT%20version()-- HTTP/1.1" 200 733 "-" "curl/8.5.0"
198.51.100.4 - - [08/Sep/2026:09:21:02 +0900] "GET /index.php HTTP/1.1" 404 153 "-" "curl/8.5.0"
203.0.113.9 - - [08/Sep/2026:09:22:40 +0900] "POST /pa;id HTTP/1.1" 500 90 "-" "python-requests/2.32"

まとめ

  • /pa が未認証なのは設計上の必然(電話機は資格情報を持たない)で、問題はその口の中で値を SQL に連結していたことである
  • 修正版は 8.4.0.2(2026年7月14日)。NVD の適用範囲は 8.2.2.1 以上 8.4.0.2 未満
  • 修正が7月、KEV 収載が9月。「見たときは静かだった」で止めた判断を、いま見直す時期にある
  • 更新できない期間は /pa の到達範囲を絞る。PBX では通話転送・外線発信の設定改ざんも必ず確認する
  • パラメータ化は「危険な文字を弾く」のではなく「値を構造にしない」。だから細工した入力がエラーにならず0件で返る

各 CVE の対応状況は CVE Watch にまとめている。


Switchvox CVE-2026-9586 認証の外側に置かれた1本のエンドポイント
https://blog.hashito.biz/2026/09/08/switchvox-cve-2026-9586-pa-endpoint-unauth-sqli/
著者
hashito
作成日
2026年9月8日
著作権