Stored 크로스 사이트 스크립팅 (Stored XSS)

Stored Cross-Site Scripting (Stored XSS)

설명

Stored XSS는 파일 시스템, DB 등 저장소에 있던 값(예: 파일명, 게시글 제목)을 적절한 출력 인코딩 없이 HTML로 렌더링할 때 발생합니다. 공격자는 악성 스크립트를 포함한 값을 저장해두고, 해당 값이 나중에 페이지에 표시될 때 스크립트가 자동 실행되도록 악용할 수 있습니다. 예를 들어, 악성 파일명을 업로드한 뒤 디렉터리 목록을 보여주는 페이지에서 그 이름을 그대로 HTML에 삽입하면 피해자가 목록을 열람하는 순간 스크립트가 실행됩니다.

잠재적 영향

  • 세션 탈취: 악성 스크립트가 읽을 수 있는 쿠키/토큰을 탈취해 로그인 세션을 가로챌 수 있음
  • 계정 탈취/권한 남용: 관리자 계정 접수, 사용자의 권한으로 임의 액션 수행 가능
  • 피싱/페이지 변조: 화면 조작, 가짜 입력창 삽입으로 사용자 기만 및 민감정보 수집
  • CSRF 유도 및 내부 자원 접근: 사용자의 브라우저 권한을 악용해 내부 API 호출, 상태 변경 수행
  • 지속성 있는 감염: 저장소에 남아 다수 사용자에게 반복적으로 악성 스크립트를 실행

해결 방법

  • 출력 인코딩: HTML 콘텐츠에 값을 넣기 전, 컨텍스트에 맞는 인코딩/이스케이프(HTML 엔티티 인코딩, 속성 컨텍스트, URL 컨텍스트 등)를 적용하세요.
  • 템플릿 엔진 자동 이스케이프 사용: ejs/pug/nunjucks 등에서 출력 이스케이프를 활성화하고, 신뢰되지 않은 값을 문자열 연결로 직접 HTML에 삽입하지 마세요.
  • 안전한 응답 포맷: 가능하면 res.json 등 스크립트 실행과 직접 연결되지 않는 포맷으로 전달하세요.
  • 입력 검증 및 정규화: 파일명 등 저장 전 단계에서 허용 문자(허용 목록)를 적용하고, 스크립트 관련 메타문자(<, >, ", ', &, / 등)를 제한하세요.
  • 보안 헤더: CSP(Content-Security-Policy), X-Content-Type-Options, HTTPOnly/Secure/SameSite 쿠키로 피해 범위를 줄이세요.
  • 라이브러리 사용: escape-html, he, validator 등 검증된 라이브러리로 인코딩/필터링을 구현하세요.

예시

변경 전

javascript
const express = require("express");
const fs = require("fs");
const app = express();

// 취약: 저장된 파일명을 그대로 HTML로 렌더링
app.get("/files", (req, res) => {
  fs.readdir("/var/app/uploads", (err, names) => {
    if (err) return res.status(500).send("error");
    const body =
      "<h2>Files</h2><ol>" +
      names.map((n) => "<li>" + n + "</li>").join("") +
      "</ol>";
    // BAD: n이 <script> 같은 태그를 포함하면 그대로 실행됨
    res.send(body);
  });
});

app.listen(3000);

변경 후

javascript
const express = require("express");
const fs = require("fs");
const escapeHtml = require("escape-html"); // 또는 he, validator 등
const app = express();

// 안전: HTML 컨텍스트에 맞는 출력 인코딩 적용
app.get("/files", (req, res) => {
  fs.readdir("/var/app/uploads", (err, names) => {
    if (err) return res.status(500).send("error");
    const safeItems = names
      .map((n) => "<li>" + escapeHtml(n) + "</li>")
      .join("");
    const body = "<h2>Files</h2><ol>" + safeItems + "</ol>";
    res.set("Content-Type", "text/html; charset=utf-8");
    res.send(body);
  });
});

// 대안: HTML 대신 JSON으로 전달 (프런트에서 안전한 텍스트 API로 출력)
app.get("/files.json", (req, res) => {
  fs.readdir("/var/app/uploads", (err, names) => {
    if (err) return res.status(500).json({ error: "error" });
    res.json({ files: names });
  });
});

app.listen(3000);

설명:

  • 변경 전: 파일 시스템에서 읽은 파일명(names)이 신뢰되지 않은 데이터인데, 이를 HTML 문자열에 그대로 연결하여 res.send로 전송합니다. 공격자가 악성 HTML을 포함한 파일명을 저장해두면, 목록 페이지를 열람하는 사용자의 브라우저에서 스크립트가 실행되어 저장형 XSS가 발생합니다.
  • 변경 후: escape-html(또는 he/validator 등)을 사용해 파일명을 HTML 엔티티로 인코딩하여 브라우저가 태그로 해석하지 못하게 합니다. 또한 JSON 응답을 활용해 스크립트 실행 컨텍스트와 분리하는 대안을 제공하여 XSS 위험을 줄입니다. JSON 값을 클라이언트에서 다시 HTML로 해석하지 않도록 하세요.

참조