61. WebSocket 보안: 실시간 통신의 숨겨진 취약점
WebSocket은 연결이 오래 유지된다는 이유만으로 안전하거나 위험해지지 않는다. 문제는 HTTP 요청마다 보이던 인증·인가·감사 경계가 연결과 프레임 안으로 이동한다는 데 있다. 테스트는 승인된 랩과 자기 소유 서비스에서만 수행하며, 다른 사용자의 채널을 구독하거나 메시지를 외부로 전송하지 않는다.
핸드셰이크를 보안 경계로 읽기
연결은 HTTP Upgrade로 시작한다.
GET /chat HTTP/1.1
Host: app.example.test
Upgrade: websocket
Connection: Upgrade
Origin: https://app.example.test
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버는 허용된 Origin과 인증 정보를 검증하고, 성공 후에도 연결의 주체와 권한을 세션에 묶어야 한다. Origin은 CSRF 방어에 유용하지만 인증 그 자체가 아니며, 비브라우저 클라이언트는 임의의 값을 보낼 수 있다. 쿠키 인증을 사용한다면 CSRF 토큰 또는 서버가 발급한 일회성 연결 토큰을 함께 요구한다.
CSWSH를 판단하는 질문
공격자 페이지에서 new WebSocket("wss://app.example.test/chat")를 호출하는 것만으로 취약점이 입증되지는 않는다. 브라우저가 쿠키를 보냈는지, 서버가 Origin을 허용했는지, 연결 뒤 민감한 메시지가 응답됐는지, 상태 변경까지 가능한지를 각각 확인한다. 랩에서는 더미 계정과 “CSRF_TEST” 문자열만 사용한다.
방어의 최소 조건은 허용 목록 기반 Origin 검증, TLS(wss://), 연결 시 인증, 메시지별 권한 검사다. 토큰을 URL 쿼리에 넣으면 프록시와 로그에 남을 수 있으므로 수명 짧은 핸드셰이크 토큰이나 Sec-WebSocket-Protocol 사용을 검토하고, 로그에서는 값을 마스킹한다.
프레임은 새로운 API다
핸드셰이크가 인증됐다고 모든 메시지가 허용되는 것은 아니다. 메시지마다 타입, 크기, 필드, 리소스 소유권을 검증한다. JSON 스키마를 적용하고 예상하지 않은 필드를 거부하면 prototype pollution 같은 자바스크립트 객체 병합 문제도 줄어든다. 브라우저 렌더링 시에는 HTML로 삽입하지 말고 텍스트로 취급한다.
function handleMessage(raw, user) {
const message = JSON.parse(raw);
if (!Number.isInteger(message.id) || message.action !== "getUser") {
throw new Error("invalid message");
}
return getUserOwnedBy(user.id, message.id);
}
서버는 최대 프레임 크기, 연결 수, 메시지 빈도, 유휴 시간을 제한하고 ping/pong과 종료 코드를 모니터링한다. 브로드캐스트 채널은 구독 권한을 별도로 검사한다. 경쟁 상태가 의심되면 공유 상태 변경을 원자적으로 처리하고 서버 로그의 연결 ID로 순서를 재구성한다.
승인된 테스트 루틴
Burp Suite의 WebSocket History나 로컬 wscat으로 실제 프레임을 관찰한다.
wscat -c 'ws://127.0.0.1:8080/ws' -H 'Origin: http://127.0.0.1:8080'
> {"action":"getUser","id":1}
테스트 행렬은 익명, 일반 사용자, 관리자 계정으로 나누고, 같은 프레임이 권한에 따라 어떻게 달라지는지 기록한다. SQL 인젝션 문자열을 넣는 것보다 먼저 숫자 타입, 누락 필드, 과도한 크기, 예상하지 않은 action을 확인한다. 에러가 내부 스택을 노출하면 메시지를 외부에 반환하지 않고 서버 로그에 상관 ID만 남긴다.
WebSocket 보안은 “연결을 열 수 있는가”의 문제가 아니라 연결이 끝날 때까지 누가 무엇을 할 수 있는가를 지속적으로 판정하는 문제다. 이 모델로 설계하면 HTTP와 다른 프로토콜이라는 사실이 보안 공백으로 바뀌지 않는다.