![[실전 보안 08] 사용자 글에 숨은 악성 스크립트](https://img.thenullpage.com/posts/5715/5715_1_42d37e.webp)
지난 편에서 방어의 무게 중심을 바깥에서 안쪽으로 옮겼다. 이제부터는 우리 코드가 직접 책임져야 하는 영역이다. 그 첫 주제는 커뮤니티 운영자가 가장 자주, 그리고 현실적으로 마주치는 위협인 교차 사이트 스크립트 공격이다. 사용자가 쓴 글이나 댓글 속에 악성 스크립트가 숨어 있다가, 그걸 읽는 다른 사용자의 브라우저에서 실행되는 공격이다. 커뮤니티는 사용자가 콘텐츠를 만드는 공간이니 이 위험을 피할 수 없다. 우리 사이트가 이 문제를 어떻게 다뤘는지 실제 경험으로 풀어 본다.
1. 남의 브라우저에서 실행되는 코드
이 공격의 무서운 점은 피해자가 아무 잘못도 하지 않는다는 것이다. 그저 게시판의 글 하나를 열어 봤을 뿐인데, 그 글 안에 심어진 스크립트가 자기 브라우저에서 실행된다. 공격자는 글이라는 형태로 코드를 심어 두고, 그 글을 읽는 모든 사람의 브라우저를 조종하는 셈이다. 게시판처럼 여러 사람이 같은 글을 보는 공간에서는 피해가 한 명에 그치지 않고 열람자 전체로 퍼진다.
그럼 그 스크립트로 무엇을 할 수 있을까. 앞서 인증 편에서 다룬 로그인 증표를 훔쳐 낼 수 있다. 브라우저에 저장된 증표를 빼내 공격자에게 보내면, 공격자는 그 사람 행세를 하며 계정을 장악한다. 화면을 몰래 바꿔 가짜 로그인 창을 띄워 비밀번호를 낚아챌 수도 있고, 피해자 몰래 그의 계정으로 글을 쓰거나 설정을 바꿀 수도 있다. 사용자 콘텐츠 하나가 사이트 전체의 신뢰를 무너뜨리는 통로가 되는 것이다.
공격이 성립하는 근본 원인은 하나였다. 사용자가 입력한 내용을 그대로 화면에 출력했기 때문이다. 사용자가 쓴 글을 브라우저가 그냥 글자로만 보여 주면 아무 문제가 없다. 그런데 그 안에 든 스크립트 표시를 브라우저가 진짜 명령으로 해석해 실행해 버리면 공격이 성립한다. 문제의 핵심은 데이터로 취급해야 할 사용자 입력을 브라우저가 실행 가능한 코드로 오해하게 방치한 데 있었다.
우리 커뮤니티는 글쓰기에서 굵은 글씨, 목록, 이미지 삽입 같은 서식을 허용한다. 즉 사용자가 순수한 글자만 넣는 게 아니라 어느 정도 구조가 있는 내용을 넣는다는 뜻이다. 그래서 모든 걸 무조건 글자로만 취급해 버릴 수도 없었다. 허용할 서식은 살리되 위험한 요소는 걸러 내야 하는, 미묘한 줄타기가 필요했다. 이 줄타기가 바로 입력 정화의 핵심 과제였다.
2. 걸러 내기와 벗겨 내기
방어의 큰 방향은 두 갈래였다. 하나는 저장하거나 출력하기 전에 위험한 요소를 제거하는 정화이고, 다른 하나는 출력할 때 사용자 입력을 코드가 아니라 글자로 취급하도록 표시를 바꾸는 이스케이프다. 상황에 따라 둘 중 하나 또는 둘 다를 쓴다. 순수한 글자만 받는 자리라면 출력 시 특수한 표시를 순한 글자 표현으로 바꾸는 것만으로 대부분 막힌다.
문제는 우리처럼 서식을 허용하는 경우였다. 사용자가 넣은 서식은 살려야 하니 무조건 글자로 바꿔 버릴 수는 없다. 그래서 필요한 게 허용 목록 방식의 정화였다. 우리가 안전하다고 판단한 서식 요소만 통과시키고, 그 목록에 없는 모든 것은 제거하는 방식이다. 굵게, 목록, 이미지처럼 우리가 명시적으로 허락한 것만 남기고, 스크립트를 비롯한 나머지는 전부 벗겨 낸다.
왜 금지 목록이 아니라 허용 목록이냐면, 위험한 것을 일일이 나열해 막는 방식은 반드시 빈틈이 생기기 때문이다. 공격 기법은 끊임없이 새로 나오고, 예상 못 한 우회 표현도 많다. 금지 목록은 새 공격이 나올 때마다 뒤쫓아 추가해야 하지만, 허용 목록은 우리가 아는 안전한 것만 통과시키니 모르는 위험은 자동으로 막힌다. 방어에서 안전한 것만 허락하고 나머지는 막는다는 이 원칙은 여러 곳에서 되풀이됐다.
정화는 언제 하느냐도 고민이었다. 저장할 때 미리 정화해 두면 출력이 빠르지만, 정화 규칙이 바뀌면 이미 저장된 글을 다시 손봐야 한다. 출력할 때마다 정화하면 규칙 변경에 유연하지만 매번 처리 비용이 든다. 나는 신뢰 경계를 넘는 시점, 그러니까 사용자 입력이 우리 화면으로 나가기 직전을 방어의 마지노선으로 삼되, 서버 쪽에서도 한 번 더 거르는 이중 구조를 뒀다.
3. 화면 검사만 믿지 않기
여기서 앞 편들에서 반복한 원칙이 또 등장한다. 정화를 화면 쪽 자바스크립트에서만 하면 안 된다는 것이다. 봇 검증 편에서 말했듯 공격자는 화면을 거치지 않고 서버에 직접 요청을 보낼 수 있다. 화면에서 아무리 깔끔하게 정화해도, 서버가 그걸 믿고 그대로 저장하면 화면을 건너뛴 악성 입력이 그대로 들어온다. 그래서 최종 정화는 반드시 신뢰할 수 있는 서버 쪽에서 이뤄져야 했다.
우리 사이트는 서버가 사용자 콘텐츠를 받을 때 허용 목록 기반 정화를 거치도록 했다. 화면 쪽 편집기가 만들어 보내는 서식도 결국 서버에서 다시 검사받는다. 편집기가 정상 서식을 만든다고 믿는 게 아니라, 무엇이 오든 서버에서 우리 허용 목록으로 한 번 걸러 낸다는 태도다. 화면 검사는 사용자에게 즉각적인 편의를 줄 뿐이고, 진짜 방어벽은 서버에 있다는 원칙을 여기서도 지켰다.
편집기 이야기가 나온 김에 덧붙이면, 우리가 쓰던 글 편집 도구가 오래되어 지원이 끝난 버전이었고 거기에 알려진 취약점이 있었다. 도구 자체를 당장 교체하긴 어려웠기에, 서버의 정화 계층이 그 취약점을 실질적으로 막아 주는 역할을 했다. 낡은 부품을 쓰더라도 그 뒤에 튼튼한 정화 관문을 세워 두면 위험을 상당히 눌러 낼 수 있다는 걸 확인한 사례였다. 물론 근본적으로는 낡은 도구를 교체하는 게 정석이지만, 방어층이 시간을 벌어 준 셈이다.
4. 링크와 이미지의 함정
정화라고 하면 스크립트 제거만 떠올리기 쉽지만, 실제로는 링크와 이미지처럼 겉보기에 무해한 요소에도 함정이 있었다. 링크는 목적지 주소에 스크립트를 실행하는 특수한 형태를 넣을 수 있고, 이미지도 불러오기 실패 같은 상황을 악용해 코드를 끼워 넣는 수법이 있다. 그래서 허용 목록에 링크나 이미지를 넣더라도, 그 안의 주소가 안전한 형식인지까지 검사해야 했다.
특히 우리 커뮤니티는 댓글의 링크 정책에 신경을 많이 썼다. 댓글에 들어가는 하이퍼링크는 우리 사이트 내부를 가리키는 링크만 허용하도록 했다. 외부의 아무 주소나 링크로 걸 수 있게 두면 스팸과 낚시의 통로가 되고, 위험한 목적지로 사용자를 유도하는 데 악용될 수 있기 때문이다. 내부 링크만 허용한다는 이 단순한 규칙이 스팸과 유해 링크를 걸러 내는 데 크게 기여했다.
글 본문에 들어가는 외부 링크에는 검색 엔진과 브라우저에 신뢰하지 말라고 알리는 표시를 붙였다. 사용자가 만든 링크임을 명시하고, 그 링크를 따라간 흔적이 우리 사이트의 신뢰를 넘겨주지 않도록 하는 표식이다. 이렇게 하면 우리 사이트가 스팸 링크의 신뢰도를 높여 주는 발판으로 악용되는 걸 줄이고, 검색 품질 측면에서도 우리를 보호할 수 있었다. 작은 표식 하나가 남용 방지와 검색 건강을 동시에 챙겨 줬다.
5. 정화 다음의 방어층
입력 정화를 촘촘히 해도 나는 마음을 완전히 놓지 않았다. 앞 편들에서 배운 대로 어떤 방어든 언젠가 우회될 수 있다는 전제를 깔았기 때문이다. 정화 로직에 미처 예상 못 한 빈틈이 있어 악성 스크립트가 한 조각 새어 나갔다고 상상해 보자. 그럴 때 그 스크립트가 실제로 실행되는 걸 한 번 더 막아 줄 최후의 방어층이 있으면 얼마나 든든할까.
그 최후의 방어층이 바로 콘텐츠 보안 정책이다. 브라우저에게 이 사이트에서는 어디서 온 스크립트만 실행하라고 미리 규칙을 알려 두는 방식이다. 그러면 설령 정화를 뚫고 악성 스크립트가 페이지에 들어왔더라도, 브라우저가 그 규칙에 어긋나는 스크립트의 실행을 거부한다. 정화가 첫 번째 벽이라면 이건 그 벽이 뚫렸을 때를 대비한 두 번째 벽이다.
그래서 다음 편에서는 이 콘텐츠 보안 정책을 다루려 한다. 어떻게 브라우저에 규칙을 전달하는지, 우리 사이트에서는 이 정책을 어디까지 적용했고 어떤 현실적 제약 때문에 어디서 타협했는지, 그리고 이 방어가 왜 입력 정화를 대체하는 게 아니라 보완하는 관계인지를 실제 경험과 함께 이야기하겠다. 하나의 위협을 여러 겹으로 막는다는 심층 방어의 정신이 가장 잘 드러나는 편이 될 것이다.