gyumin.log

반사형 XSS, 내 검색창으로 직접 찔러보기

🌱 씨앗

목차

내 블로그 검색창에 누가 공격 문자열을 넣으면 어떻게 되는지 직접 찔러보고 정리한 노트. 한 줄로 줄이면 **“정적 사이트라 SQL 인젝션은 자리 자체가 없고, 반사형 XSS는 Pagefind가 기본으로 막고 있더라”**는 이야기다. 화면만 보고 “안 터지네”에서 끝내지 않고, 개발자도구로 DOM을 열어 안 터지는지까지 확인한 게 이번 실습의 핵심이다.

시작: 내 사이트에 SQL 인젝션이 통할까

먼저 SQL 인젝션(SQLi)이 걱정돼서 봤는데, 이 사이트엔 통할 자리가 없었다. “인젝션”은 내가 넣은 입력이 데이터가 아니라 명령으로 해석될 때 생기는데, 어디서 해석되느냐로 갈래가 나뉜다.

  • SQL 인젝션: 입력이 서버의 SQL 쿼리 문자열에 끼어든다. 표적은 서버와 DB. ' OR 1=1 -- 로 조건을 무력화해 남의 데이터를 열람·변조한다. 서버와 DB가 있어야 성립한다.
  • XSS: 입력이 페이지 HTML에 태그로 끼어든다. 표적은 방문자의 브라우저. <script> 가 방문자 쪽에서 실행돼 세션 탈취·화면 변조로 이어진다. 서버가 없어도 성립한다.

이 블로그는 Astro를 정적(static)으로 빌드하고, 검색은 Pagefind가 브라우저 안에서 한다. 빌드 때 만들어 둔 정적 인덱스를 방문자 브라우저가 직접 조회할 뿐이라, 검색어가 서버로 가지 않는다. DB도 쿼리도 없으니 조립될 SQL이 없다. 검색창에 '; DROP TABLE posts; -- 를 넣어도 그건 그냥 검색어다.

그래서 실제로 신경 쓸 표면은 XSS, 그중에서도 입력이 곧바로 화면에 되비치는 검색창 = 반사형 XSS였다.

반사형 XSS가 뭔가

한 문장으로: 내가 넣은 입력이 그대로 페이지에 되돌아 그려질 때, 그게 글자가 아니라 코드로 해석되면 뚫린다. 입력이 화면에 남는 방식으로 셋을 나눈다.

  • 반사형(Reflected): 입력이 그 즉시 응답 화면에 되비침. 검색어, 에러 메시지, URL 파라미터.
  • 저장형(Stored): 입력이 DB에 저장됐다 나중에 표시됨. 댓글, 게시글. 더 위험하다.
  • DOM 기반(DOM-based): 서버 없이 JS가 입력을 DOM에 꽂음. location.hash 를 innerHTML로 넣는 식.

검색창은 입력을 즉시 되비추니 반사형의 교과서적 표면이다. 그래서 여기를 표적으로 삼았다.

왜 뚫리고 왜 막히나

갈림길은 딱 하나다. 개발자가 입력을 HTML로 취급해 꽂느냐, 글자로 취급해 넣느냐.

  • 뚫리는 패턴: el.innerHTML = 입력값 처럼 입력을 HTML로 파싱해 넣는다. <img onerror> 가 진짜 태그가 되어 스크립트가 실행된다.
  • 막히는 패턴: el.textContent = 입력값 을 쓰거나, HTML로 넣더라도 <&lt; 로 이스케이프한다. 브라우저가 “그릴 태그”가 아니라 “보여줄 글자”로 처리한다.

내 사이트가 후자인 건 내가 방어 코드를 짜서가 아니라, 검색을 통째로 맡긴 Pagefind UI가 내부적으로 이스케이프하기 때문이다. 라이브러리에 의존한 안전이라는 점을 기억해 둔다.

직접 해봤다: 두 번의 공격

빌드 후 preview에서 /search 를 열고, 개발자도구(F12)로 DOM을 열어놓은 채 페이로드를 넣었다.

1. 결과 없음 메시지에 반사되는 검색어

어느 글에도 안 걸릴 문자열을 넣으면 "[검색어]에 대한 결과가 없습니다" 문구에 그대로 반사된다. 고전적인 이미지 onerror 페이로드를 넣었다.

<img src=x onerror=alert('XSS-test')>

콘솔에서 그 메시지 요소의 innerHTML을 열어보니 이렇게 들어가 있었다.

"&lt;img src=x onerror=alert('XSS-test')&gt;"에 대한 결과가 없습니다

<>&lt; &gt; 로 인코딩됐다. img 요소는 생성 0개, alert도 안 떴다. 화면엔 페이로드가 글자 그대로 찍혔다.

2. 결과가 나오는 검색어에 태그 섞기

결과가 있을 때는 제목·발췌·하이라이트를 그리는 렌더링 경로를 탄다. 여기도 안전한지 봐야 완결이라, 실제로 매칭되는 단어에 태그를 섞었다.

보안<script>alert(1)</script>

“보안”이 매칭돼 결과가 66건 나왔다. 결과 영역을 전수 검사했더니 주입된 <script> 태그 0개, onerror·onload 같은 이벤트 핸들러 0개였다. 결과에 보이는 <mark> 하이라이트는 Pagefind가 매칭된 단어에 씌운 자기 마크업이지, 내 입력에서 흘러든 게 아니다.

// 결과 영역에 실제 script / 이벤트 핸들러가 주입됐는지 전수 검사
const root = document.querySelector('.pagefind-ui');
root.querySelectorAll('script').length;                        // 0 이면 안전
root.querySelectorAll('[onerror],[onload],[onclick]').length;  // 0 이면 안전

두 반사 표면(에러 메시지 / 결과 렌더링) 모두 입력을 글자로만 취급했다.

지금 안전한 이유, 그리고 사라지는 순간

지금 안전한 건 Pagefind에 검색을 통째로 맡겼기 때문이다. 이 방어는 내가 직접 화면에 무언가를 찍는 코드를 짜는 순간 사라진다. URL 파라미터(?q=...)나 검색 결과를 받아 innerHTML·document.write 로 화면에 찍으면 그 자리에서 반사형 XSS가 생긴다.

원칙은 둘뿐이다.

  1. 입력은 항상 textContent 로 넣는다.
  2. 굳이 HTML로 넣어야 하면 이스케이프하거나 검증된 새니타이저(예: DOMPurify)를 거친다.

한 겹 더 두르려면 응답 헤더에 CSP(Content-Security-Policy)를 걸어 인라인 스크립트 실행 자체를 차단할 수 있다. Cloudflare Pages라면 _headers 파일로 얹는 게 다음 실습 후보다.

더 찔러볼 것들

/search 에서 아래를 넣고, 콘솔에서 document.querySelector('.pagefind-ui__message').innerHTML 로 어떻게 인코딩됐는지 반복 확인하면 감이 붙는다.

  • "><script>alert(document.domain)</script> : 속성 탈출 시도
  • <svg onload=alert(1)> : img 말고 svg 벡터
  • javascript:alert(1) : 링크 스킴 주입
  • <a href="javascript:alert(1)">click</a> : 클릭 유도형

용어

  • 반사형 XSS: 입력이 그 즉시 응답 화면에 되비치며 실행되는 XSS. 검색어·에러 메시지가 대표 표면.
  • 저장형 / DOM 기반 XSS: 입력이 저장됐다 표시되면 저장형, 서버 없이 JS가 DOM에 꽂으면 DOM 기반.
  • 페이로드: 공격을 실제로 수행하는 입력 문자열. 여기선 <img onerror> 같은 태그.
  • 이스케이프 / HTML 엔티티: <&lt; 로 바꿔 태그가 아닌 글자로 만드는 처리.
  • innerHTML vs textContent: 전자는 문자열을 HTML로 파싱(위험), 후자는 글자로만 삽입(안전).
  • 새니타이즈: 위험한 태그·속성을 걸러내고 안전한 것만 남기는 정제. 예: DOMPurify.
  • Pagefind: 정적 사이트용 클라이언트 검색 라이브러리. 빌드 때 만든 인덱스를 브라우저에서 조회.
  • CSP: Content-Security-Policy. 어떤 스크립트·리소스를 허용할지 헤더로 지정해 XSS를 한 겹 더 막는 방어.