사이트 보안점검 후기

2026-04-03·1분 읽기·

사이트 보안점검 후기

오랜만에 Next.js 사이트를 직접 뒤적거리며 보안 점검을 했다. 발견한 문제들과 어떻게 고쳤는지 실제 커맨드 결과랑 같이 정리.

기존 Wordpress 기반 홈페이지에서 Nextjs 기반 홈페이지로 최근 마이그래이션을 했다. 완료 후 보안 점검 진행해보았고 결과를 정리해보았다.

hero
hero

1. 보호 글인데 본문이 서버에서 그대로 나가고 있었음

이게 제일 황당했는데, 비밀번호 걸어둔 글도 SSR(서버 사이드 렌더링) 단계에서 본문을 통째로 응답에 포함시키고 있었다.

즉, 브라우저에서 잠금 화면처럼 보여도 서버 응답을 잡아보면 본문이 다 있다. 기존에 .env.local로 암호를 지정해놨는데 암호 우회가 가능한 상태였다.

보호글 접근 시 로그인 페이지로 가로막힌다
보호글 접근 시 로그인 페이지로 가로막힌다

지금은 보호글에 접근하면 로그인 페이지로 막힌다. 수정 전엔 잠금 화면처럼 보여도 서버 응답엔 본문이 다 들어 있었다.

고친 방법: 서버는 제목·요약 같은 메타 정보만 내려보내고, 본문은 클라이언트가 비밀번호 확인 후 별도 API에서 받아오게 바꿨다.

// pseudo
if (post.protected) {
  return <PasswordGate slug={slug} />;  // 서버엔 본문 없음
} else {
  return <MDXRemote source={content} />;
}

2. 마크다운을 sanitize 없이 바로 출력하고 있었음

나는 포스트 작성 시 mdx를 이용하는데, 관리자 채팅 UI에서 마크다운 → HTML 변환 후 바로 dangerouslySetInnerHTML에 적용하고 있었다. 그 상태면 [클릭](javascript:alert(1)) 같은 입력으로 XSS가 터진다.

관리자 전용 UI라도 안심할 수 없다. 관리자 계정이 털리면 그게 공격자 입력이 되는 거니까.

고친 방법: marked()로 HTML 만들고 → sanitize-html로 허용 목록 기반 필터링.

const raw = marked(mdText);
const safe = sanitizeHtml(raw, {
  allowedTags: ['p','strong','em','a','ul','ol','li','pre','code','table'],
  allowedSchemes: ['http','https','mailto'],
  transformTags: { a: (_, attrs) => ({ tagName: 'a', attribs: { ...attrs, target: '_blank', rel: 'noopener noreferrer' } }) }
});

sanitize-html은 허용 목록에 없는 태그/속성을 전부 날린다. javascript: URL도 차단. 간단한데 효과적이다.


3. CSP 헤더가 아예 없었음

CSP(Content Security Policy)는 브라우저한테 "이 사이트에서 허용된 리소스만 불러와" 라고 강제하는 헤더다. 없으면 XSS 성공했을 때 외부 스크립트 로드 같은 2차 공격이 훨씬 쉬워진다.

next.config.ts에 headers() 추가하는 거라 코드 몇 줄짜리 작업이었다.

실제 curl -I 응답 — 보안 헤더 7개 확인
실제 curl -I 응답 — 보안 헤더 7개 확인

curl -I https://zino.kr/로 실제 응답 헤더를 찍어봤다. CSP 포함 7개 보안 헤더가 전부 붙어있다.

적용한 헤더 목록:

  • Content-Security-Policy — 스크립트/리소스 출처 제어
  • X-Frame-Options: DENY — clickjacking 방지
  • X-Content-Type-Options: nosniff — MIME 타입 위조 차단
  • X-XSS-Protection: 0 — 폐기된 레거시 필터(자체 취약점 존재)라 의도적으로 비활성. 실질 XSS 방어는 위 CSP가 담당
  • Referrer-Policy — 참조 URL 노출 제어
  • Permissions-Policy — 카메라/마이크/위치 접근 차단
  • Strict-Transport-Security — HTTPS 강제


4. 파일 업로드 — 확장자만 보고 있었음

업로드 API에서 파일명 끝이 .png면 그냥 통과시키고 있었다. malware.php를 malware.png로 이름만 바꿔서 올리면?

PHP 코드를 .png로 위장 업로드 시 차단
PHP 코드를 .png로 위장 업로드 시 차단

PHP 코드를 담은 파일을 .png로 위장해서 올렸더니 바로 차단된다. file-type 라이브러리로 실제 파일 내용(매직바이트)을 확인한다.

매직바이트(magic bytes)란 파일 맨 앞에 있는 고정 바이트 시퀀스다. .png 파일이라면 앞 8바이트가 89 50 4E 47 ...이어야 하는데, PHP 파일을 열어보면 3C 3F 70 68 70(= <?php)으로 시작한다. 이걸 보면 속임수가 바로 들통난다.

추가로 SVG는 내부에 <script> 태그나 on* 이벤트 속성을 심을 수 있어서 따로 텍스트 파싱으로 제거했다.


5. SSRF — 서버가 내부 IP로 요청하는 거 막아야 함

SSRF(Server-Side Request Forgery)는 "서버가 나 대신 요청을 보내줘" 하는 공격이다. 사용자가 IP를 입력하면 서버에서 그 IP로 fetch하는 기능이 있었는데, 여기에 192.168.1.1 같은 내부 IP를 넣으면 서버가 내부 네트워크에 접근하게 된다.

입력 IP를 검증해서 private/loopback/link-local 주소는 전부 막았다.

SSRF 차단 — 내부 IP 차단 / 외부 IP 통과
SSRF 차단 — 내부 IP 차단 / 외부 IP 통과

내부 IP(192.168.1.1, 127.0.0.1)는 Private or reserved IP not allowed로 차단, 외부 공개 IP(8.8.8.8)는 정상 처리된다.


6. Rate Limit — 로그인만 제한하고 있었음

로그인 엔드포인트만 rate limit이 있고 업로드, 채팅, 답변 API에는 없었다. 브루트포스 공격이나 봇 요청에 무방비였다.

로그인 brute-force — 7번째 시도에서 RATE_LIMITED
로그인 brute-force — 7번째 시도에서 RATE_LIMITED

6번까진 틀린 비밀번호 응답, 7번째부터 RATE_LIMITED. 실제로 작동하는 거 확인.

중앙화된 rate-limiter를 만들어서 로그인, 업로드, 채팅, 답변 등 주요 엔드포인트에 각각 한도를 뒀다. 지금은 인메모리라 서버 재시작하면 초기화되는데 나중에 Redis로 바꿀 예정.


7. 보안 알림 — 이상 이벤트는 바로 알아야 함

로그인 실패, rate limit 초과, 비정상 파일 업로드 같은 이벤트가 생겨도 아무 알림이 없었다. 실시간으로 알아야 대응이 빠른데.

관리 채널로 이벤트를 보내도록 추가했다. 토큰은 환경변수, 알림은 비동기 처리(실패해도 서비스에 영향 없게).

테스트 겸 로그인 brute-force + 파일 위조 업로드를 직접 날려봤더니 이렇게 알림이 왔다.

Telegram 보안 알림 — 로그인 실패 → Rate Limit → 비정상 파일 업로드
Telegram 보안 알림 — 로그인 실패 → Rate Limit → 비정상 파일 업로드

🚨 Admin 로그인 실패 6번, ⚠️ Rate Limit 초과, ⚠️ 비정상 파일 업로드 시도까지 실시간으로 Telegram에 도착했다.


마무리

오늘 작업 요약:

항목상태
Protected 글 본문 노출✅ 수정
마크다운 XSS sanitize✅ 적용
CSP + 보안 헤더 7개✅ 적용
파일 매직바이트 검증 + SVG sanitize✅ 적용
Rate limit 전 API 확장✅ 적용
SSRF — Private IP 차단✅ 적용
보안 이벤트 알림✅ 적용

쿠키 옵션(HttpOnly, Secure, SameSite)은 이미 맞게 설정돼 있었다.

다음에 할 것: CSP에서 'unsafe-inline' 없애기(nonce 기반으로 전환), AV 스캔 파이프라인 연동, Redis 레이트 리미터 전환.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 4] php.ini 한 줄로 SQL Injection 이 막히던 시절 — DreamHack [CodeEngn] Malware L04 풀이
blog

[🥈 Silver 4] php.ini 한 줄로 SQL Injection 이 막히던 시절 — DreamHack [CodeEngn] Malware L04 풀이

배포 파일이 아예 없고 두 줄짜리 질문만 있다. "APM 사이트가 SQL Injection 공격을 받는 중인데 PHP 설정파일에서 어떤 옵션을 켜야 안전한가." 답은 알겠는데 근거가 얇아서, 그 옵션이 실제로 동작하는 마지막 세대인 Debian 6 (Apache 2.2 / PHP 5.3.3 / MySQL 5.1) 을 도커로 세워 Off/On 을 대조했다. 두 종류의 인젝션이 실제로 막히고, 그럼에도 숫자 파라미터와 GBK 우회는 그대로 통과한다.
#dreamhack#ctf#misc+7
2026-08-19#hardening
[💎 Diamond 1] Math.exp가 NaN을 잊을 때 (4) — 근데 .at()이 열려 있었다: 놓친 비-CheckBounds sink — DreamHack EXP-NaN
blog

[💎 Diamond 1] Math.exp가 NaN을 잊을 때 (4) — 근데 .at()이 열려 있었다: 놓친 비-CheckBounds sink — DreamHack EXP-NaN

3편은 "남은 출구를 전수로 두드렸지만 다 닫혔다"로 끝났다. 4편은 그 결론을 내 손으로 뒤집는 기록이다 — 3편이 본 sink는 전부 bracket 접근(`a[i]`)과 CheckBounds 계열이었는데, 딱 하나 안 본 게 있었다. `Array.prototype.at()`. 이건 별도 reducer(js-call-reducer.cc)라 경계검사를 CheckBounds가 아니라 fold 가능한 NumberLessThan 두 개 + bare LoadElement 로 한다. 패치가 그 파일을 안 건드려서 그대로 살아있고, reshape로 typer를 속이면 두 가드가 접혀 무가드 OOB read가 진짜로 열린다(자연 티어업, exit0, 결정적). 다만 store 짝이 없어 write는 여전히 막혀 있고(bracket=trap, TypedArray/fill/with 전멸, a.length는 치유), read→write 다리는 addrof/fakeobj뿐이다. 포인터 압축이 왜 reader 타입을 가르는지, 그리고 그 fakeobj 부트스트랩(타입-매칭 인접)이 왜 nursery monoculture에 막히는지 — V8 reducer 구조부터 쉘 스샷까지 한 단계씩.
#dreamhack#ctf#pwn+7
2026-06-07#hardening
[💎 Diamond 1] Math.exp가 NaN을 잊을 때 (3) — 남은 출구를 전부 두드리다: 의도된 sink마저 막혀 있었다 — DreamHack EXP-NaN
blog

[💎 Diamond 1] Math.exp가 NaN을 잊을 때 (3) — 남은 출구를 전부 두드리다: 의도된 sink마저 막혀 있었다 — DreamHack EXP-NaN

2편은 "표준 값-range 경로(CheckBounds 제거)는 11.2에서 닫혔다"까지였다. 3편은 그 뒤 — CheckBounds를 거치지 않는 *비표준 출구*를 하나하나 전부 두드린 기록이다. fill/copyWithin, foldable 빌트인, TypedArray·DataView, 요소종류 전이, 표현형 변환까지 후보 sink를 전수로 d8에 때려봤고 전부 정화/트랩/in-range로 죽었다. 그리고 가장 뜻밖의 발견 — 이 버그류의 *의도된* 해법인 new Array(len) length-construction마저 이 빌드에선 막혀 있다. 패치가 typer.cc만 건드리고 js-create-lowering의 길이 보호는 안 풀어서다. 어긋남은 살아있는데(자연 티어업에서 INT_MIN으로 발산) 받아줄 sink가 없다. 끝으로 남은 한 수 — 배포 바이너리 직접 부검.
#dreamhack#ctf#pwn+7
2026-06-07#hardening