
XSS, CSRF, SQL Injection은 공격 방식은 다르지만 모두 같은 지점에서 시작됩니다.
바로 브라우저와 서버가 주고받는 HTTP 요청(Request)과 응답(Response)입니다.
본격적으로 각 공격을 다루기 전에, 그 배경이 되는 브라우저-서버 통신 구조부터 짚고 넘어가려 합니다.
서버와 브라우저는 HTTP 통신으로 HTML을 주고받으며 화면을 그립니다.
https://example.com/login
위 주소로 접속하면 브라우저는 다음과 같은 GET 요청을 서버로 보냅니다.
GET /login HTTP/1.1
Host: example.com
서버는 이에 대해 다음과 같은 응답을 HTML 코드와 함께 반환합니다.
HTTP/1.1 200 OK
Content-Type: text/html
여기서 중요한 건, 브라우저는 서버를 전적으로 신뢰하고 이 HTML을 의심 없이 렌더링한다는 점입니다. 이 신뢰가 나중에 XSS 공격의 출발점이 됩니다.
HTTP는 Stateless 프로토콜입니다. 즉, 서버는 이전에 어떤 요청이 있었는지 기억하지 않습니다.
그래서 서버는 지금 들어온 요청이 "로그인된 사용자의 요청인지" 자체를 알 방법이 없습니다. 매 요청이 서버 입장에서는 완전히 새로운 요청이기 때문입니다.
이 문제를 해결하기 위해 등장하는 것이 쿠키와 세션입니다.
로그인이 성공하면 서버는 다음과 같은 응답을 보냅니다.
Set-Cookie: SESSION=abc123
브라우저는 이 응답을 받아 쿠키에 저장하고, 이후 같은 도메인으로 요청을 보낼 때마다 이 쿠키를 자동으로 함께 전송합니다.
세션은 서버가 사용자를 기억하는 방식입니다.
서버는 세션 저장소에 abc123이라는 세션 값과 해당 사용자의 정보를 매핑해두고, 요청이 들어올 때마다 쿠키에 담긴 세션 값으로 "이 요청을 보낸 사람이 누구인지"를 즉시 식별합니다.
즉, 쿠키가 신분증 역할을 하고 세션은 그 신분증과 실제 사용자를 연결해주는 장부인 셈입니다.
앞에서 브라우저는 서버로부터 받은 HTML을 해석하고, 쿠키를 저장하며, JavaScript를 실행하는 역할을 한다고 설명했습니다. 이러한 동작은 웹 서비스를 정상적으로 이용하기 위해 반드시 필요한 기능입니다.
그렇다면 브라우저의 이러한 동작을 악용하면 어떤 일이 발생할까요?
이번에는 브라우저의 동작 방식을 이용한 대표적인 공격인 XSS(Cross-Site Scripting) 에 대해 알아보겠습니다.
XSS(Cross-Site Scripting)는 공격자가 웹 페이지에 악성 JavaScript 코드를 삽입하여 다른 사용자의 브라우저에서 실행되도록 만드는 공격입니다.
공격이 성공하면 단순히 alert() 창을 띄우는 것에서 끝나는 것이 아니라, 사용자의 정보를 탈취하거나 화면을 변조하는 등 다양한 피해를 일으킬 수 있습니다.
하지만 여기서 한 가지 의문이 생깁니다.
브라우저는 왜 공격자가 작성한 JavaScript를 실행하는 걸까요?
이를 이해하려면 먼저 브라우저가 HTML 문서를 어떻게 처리하는지 알아야 합니다.
사용자가 웹 페이지에 접속하면 브라우저는 서버로부터 HTML 문서를 받아옵니다.
이후 브라우저는 HTML을 위에서 아래로 읽으며 문서를 해석합니다.
이 과정에서 <script> 태그를 만나면 브라우저는 해당 내용을 JavaScript 코드라고 판단하고 JavaScript 엔진에게 실행을 요청합니다.
즉, 브라우저의 동작은 다음과 같습니다.
HTML 다운로드
↓
HTML 파싱(Parser)
↓
DOM(Document Object Model) 생성
↓
<script> 태그 발견
↓
JavaScript 실행
여기서 중요한 점은 브라우저는 <script> 태그가 누가 작성했는지 구분하지 않는다는 것입니다.
개발자가 작성한 코드인지, 사용자가 입력한 코드인지 구분하지 않고, HTML 문서 안에 존재하는 <script> 태그라면 모두 정상적인 코드로 인식하여 실행합니다.
이것이 바로 XSS가 발생하는 근본적인 원인입니다.
게시판 서비스를 예로 들어보겠습니다.
사용자가 게시글을 작성하면 서버는 입력받은 내용을 데이터베이스에 저장합니다.
만약 서버가 입력값을 아무런 검증이나 변환 없이 그대로 저장한다면 다음과 같은 내용도 함께 저장될 수 있습니다.
안녕하세요.
<script>
alert("XSS");
</script>
이후 다른 사용자가 해당 게시글을 조회하면 서버는 저장된 내용을 그대로 응답합니다.
브라우저는 이를 단순한 문자열이 아닌 HTML 문서의 일부로 해석하게 되고, <script> 태그를 발견하는 순간 JavaScript를 실행합니다.
결국 공격자의 코드가 다른 사용자의 브라우저에서 실행되는 것입니다.
즉, XSS는 브라우저의 버그가 아니라 브라우저가 HTML을 해석하는 정상적인 동작을 악용한 공격이라고 볼 수 있습니다.
공격자가 실행한 JavaScript는 현재 웹 페이지와 동일한 권한을 가지게 됩니다.
이를 이용하면 다음과 같은 공격이 가능합니다.
특히 HttpOnly 옵션이 적용되지 않은 쿠키는 JavaScript에서 접근할 수 있기 때문에 세션 정보가 탈취될 위험도 존재합니다.
가장 기본적인 방법은 사용자 입력을 HTML이 아닌 문자열로 처리하는 것입니다.
예를 들어
<script>alert("XSS")</script>
를 그대로 출력하는 것이 아니라
<script>alert("XSS")</script>
처럼 변환하면 브라우저는 이를 HTML 태그가 아닌 일반 텍스트로 인식하게 됩니다.
서비스에 따라 HTML 입력 자체가 필요한 경우도 있습니다.
예를 들어 Markdown이나 게시판 에디터처럼 <b>, <i> 등의 태그는 허용해야 하는 경우가 있습니다.
이때는 허용된 태그만 남기고 <script>와 같은 위험한 태그는 제거하는 Sanitizing 기법을 사용합니다.
CSP는 브라우저에게 어떤 JavaScript만 실행을 허용할지 알려주는 보안 정책입니다.
예를 들어 신뢰된 도메인에서 제공하는 스크립트만 실행하도록 설정하면 공격자가 삽입한 악성 스크립트의 실행을 차단할 수 있습니다.
HttpOnly 옵션을 적용하면 JavaScript에서 쿠키에 접근할 수 없습니다.
따라서 XSS가 발생하더라도 세션 쿠키를 탈취하는 공격을 어느 정도 방어할 수 있습니다.
다만 HttpOnly는 XSS 자체를 막는 것이 아니라 피해를 줄여주는 보안 옵션이라는 점을 기억해야 합니다.
Spring에서는 여러 방법으로 XSS를 예방할 수 있습니다.
하지만 어떤 프레임워크를 사용하더라도 사용자의 입력을 신뢰해서는 안 된다는 원칙은 변하지 않습니다.
실무에서는 다음과 같은 이유로 XSS가 발생하는 경우가 많습니다.
innerHTML이나 dangerouslySetInnerHTML을 검증 없이 사용하는 경우프레임워크는 많은 부분을 보호해 주지만, 개발자가 안전하지 않은 방식을 사용하면 XSS는 언제든지 발생할 수 있습니다.
앞에서 브라우저는 로그인 이후 서버로 요청을 보낼 때 쿠키를 자동으로 함께 전송한다고 설명했습니다.
이 기능 덕분에 사용자는 매번 아이디와 비밀번호를 입력하지 않아도 로그인 상태를 유지할 수 있습니다.
하지만 이 편리한 기능은 공격자에게도 악용될 수 있습니다.
이번에는 브라우저의 쿠키 자동 전송 기능을 악용하는 대표적인 공격인 CSRF(Cross-Site Request Forgery) 에 대해 알아보겠습니다.
CSRF는 공격자가 사용자의 의도와 상관없이 로그인된 사용자의 권한으로 요청을 보내도록 만드는 공격입니다.
쉽게 말해 공격자는 사용자의 계정을 직접 탈취하는 것이 아니라, 브라우저가 자동으로 인증 정보를 포함해 요청을 보내는 특성을 이용합니다.
예를 들어 사용자가 인터넷 뱅킹에 로그인한 상태에서 악성 사이트를 방문했다면, 공격자는 사용자가 클릭하지 않았더라도 송금 요청을 보내도록 만들 수 있습니다.
그렇다면 브라우저는 왜 이런 요청을 보내는 걸까요?
앞에서 설명했듯이 HTTP는 상태를 저장하지 않는(Stateless) 프로토콜입니다.
따라서 서버는 사용자가 로그인했는지 기억하지 못합니다.
이를 해결하기 위해 브라우저는 해당 사이트로 요청을 보낼 때 저장되어 있는 쿠키를 자동으로 함께 전송합니다.
동작 과정은 다음과 같습니다.
사용자 로그인
↓
브라우저가 세션 쿠키 저장
↓
같은 사이트로 요청 발생
↓
브라우저가 쿠키를 자동으로 포함
↓
서버가 로그인된 사용자라고 판단
이 기능은 정상적인 서비스 이용을 위해 반드시 필요한 기능입니다.
하지만 브라우저는 "이 요청이 사용자가 직접 만든 요청인지", "다른 사이트가 만든 요청인지" 까지는 판단하지 않습니다.
이 점이 바로 CSRF가 발생하는 원인입니다.
예를 들어 사용자가 은행 사이트에 로그인했다고 가정해 보겠습니다.
이후 사용자가 공격자가 만든 악성 사이트에 접속합니다.
악성 사이트에는 다음과 같이 송금 요청을 보내는 코드가 포함되어 있습니다.
<form action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="account" value="attacker">
<input type="hidden" name="amount" value="100000">
</form>
<script>
document.forms[0].submit();
</script>
사용자는 단순히 악성 사이트를 방문했을 뿐이지만, 브라우저는 은행 사이트의 쿠키를 자동으로 포함하여 요청을 전송합니다.
결과적으로 서버는 이를 정상적인 사용자 요청으로 판단하고 송금을 처리할 수도 있습니다.
즉, 공격자는 로그인 정보를 훔치지 않았음에도 사용자의 권한을 이용해 요청을 수행할 수 있습니다.
공격 대상 서비스에 따라 다양한 요청을 강제로 수행할 수 있습니다.
예를 들어
중요한 점은 공격자가 사용자의 권한을 그대로 이용한다는 것입니다.
가장 대표적인 방어 방법입니다.
서버는 사용자마다 예측하기 어려운 토큰을 발급하고, 요청마다 해당 토큰이 함께 전달되는지 확인합니다.
공격자는 이 토큰을 알 수 없기 때문에 정상적인 요청을 위조하기 어렵습니다.
쿠키에 SameSite 속성을 설정하면 다른 사이트에서 발생한 요청에는 쿠키가 자동으로 포함되지 않도록 제한할 수 있습니다.
최근 브라우저에서는 SameSite=Lax가 기본값으로 적용되는 경우가 많아 과거보다 CSRF 위험이 줄어들었습니다.
서버는 요청이 어떤 사이트에서 시작되었는지 확인할 수 있습니다.
신뢰하지 않는 출처에서 온 요청이라면 거부하여 공격을 방어할 수 있습니다.
비밀번호 변경이나 송금처럼 중요한 작업은 비밀번호를 다시 입력하거나 추가 인증을 요구하는 것도 좋은 방법입니다.
Spring Security는 기본적으로 CSRF 방어 기능을 제공합니다.
사용자가 폼을 제출하면 CSRF 토큰을 함께 전송하고, 서버는 이 토큰이 올바른지 확인합니다.
토큰이 없거나 일치하지 않으면 요청을 거부합니다.
다만 REST API에서는 인증 방식에 따라 설정이 달라질 수 있습니다.
예를 들어 JWT를 Authorization 헤더로만 전달하는 경우에는 일반적인 세션 기반 인증과 달리 CSRF 위험이 크게 줄어들기 때문에, 프로젝트 환경에 맞게 CSRF 기능을 활성화하거나 비활성화해야 합니다.
실무에서는 다음과 같은 오해가 자주 발생합니다.
특히 CORS와 CSRF는 서로 다른 문제입니다.
CORS는 브라우저의 교차 출처 요청을 제어하는 정책이고, CSRF는 브라우저의 자동 쿠키 전송을 악용하는 공격이므로 각각 별도의 대응이 필요합니다.
앞에서 살펴본 XSS와 CSRF는 브라우저의 정상적인 동작을 악용하는 공격이었습니다.
이번에는 시선을 브라우저가 아닌 데이터베이스(Database) 로 옮겨보겠습니다.
웹 서비스는 사용자의 로그인 정보, 게시글, 댓글, 상품 정보 등 대부분의 데이터를 데이터베이스에 저장합니다. 그리고 사용자의 요청이 들어오면 필요한 데이터를 조회하거나 수정하기 위해 SQL을 실행합니다.
그렇다면 사용자의 입력이 SQL 문장의 일부가 된다면 어떤 일이 발생할까요?
이번에는 데이터베이스의 SQL 해석 방식을 악용하는 대표적인 공격인 SQL Injection에 대해 알아보겠습니다.
SQL Injection은 사용자의 입력을 이용해 의도하지 않은 SQL 문장을 실행하도록 만드는 공격입니다.
공격이 성공하면 인증 우회, 개인정보 조회, 데이터 수정 및 삭제 등 심각한 피해가 발생할 수 있습니다.
하지만 데이터베이스는 왜 사용자의 입력을 SQL 코드로 인식하는 걸까요?
이를 이해하려면 먼저 SQL이 어떻게 실행되는지 알아야 합니다.
사용자가 로그인을 시도하면 서버는 아이디와 비밀번호를 이용해 SQL을 생성합니다.
예를 들어 다음과 같은 로그인 기능이 있다고 가정해 보겠습니다.
SELECT *
FROM users
WHERE id = 'admin'
AND password = '1234';
데이터베이스는 전달받은 SQL 문자열을 읽고 문법을 분석한 뒤 실행합니다.
동작 과정은 다음과 같습니다.
사용자 입력
↓
서버가 SQL 생성
↓
DB가 SQL 문법(Parser) 분석
↓
실행 계획 생성
↓
SQL 실행
여기서 중요한 점은 데이터베이스는 전달받은 SQL 문자열만 보고 실행한다는 것입니다.
즉, 어떤 부분이 개발자가 작성한 코드이고 어떤 부분이 사용자가 입력한 데이터인지는 알지 못합니다.
이것이 SQL Injection이 발생하는 근본적인 원인입니다.
다음과 같이 문자열을 이어 붙여 SQL을 만드는 코드를 생각해 보겠습니다.
String sql =
"SELECT * FROM users WHERE id = '" +
id +
"' AND password = '" +
password +
"'";
정상적인 입력이라면 다음과 같은 SQL이 생성됩니다.
SELECT *
FROM users
WHERE id = 'admin'
AND password = '1234';
하지만 공격자가 비밀번호 대신 다음과 같은 값을 입력한다면 어떻게 될까요?
' OR '1'='1
최종적으로 생성되는 SQL은 다음과 같습니다.
SELECT *
FROM users
WHERE id = 'admin'
AND password = '' OR '1'='1';
'1'='1'은 항상 참(True)이므로 조건이 우회되어 인증이 통과될 수 있습니다.
즉, 데이터베이스는 공격을 당한 것이 아니라 전달받은 SQL을 정상적으로 실행했을 뿐입니다.
취약한 서비스에서는 다음과 같은 공격이 가능합니다.
취약점의 정도에 따라 서비스 전체가 위험해질 수도 있습니다.
가장 효과적인 방어 방법입니다.
PreparedStatement는 SQL 코드와 사용자 입력을 분리하여 처리합니다.
즉, 사용자가 어떤 값을 입력하더라도 데이터베이스는 이를 SQL 코드가 아닌 데이터로만 인식합니다.
JPA나 MyBatis와 같은 ORM 또는 SQL 매퍼를 사용하면 대부분의 SQL은 PreparedStatement 방식으로 실행됩니다.
다만 Native Query처럼 직접 SQL을 작성하는 경우에는 여전히 주의해야 합니다.
아이디에는 아이디 형식만, 숫자에는 숫자만 입력받도록 검증하는 것도 도움이 됩니다.
다만 입력값 검증만으로 SQL Injection을 완전히 막을 수는 없으며, 반드시 PreparedStatement와 함께 사용해야 합니다.
데이터베이스 계정에는 필요한 권한만 부여해야 합니다.
예를 들어 조회만 필요한 서비스라면 DELETE나 DROP 권한까지 줄 필요는 없습니다.
공격이 성공하더라도 피해를 최소화할 수 있습니다.
Spring에서는 대부분의 경우 PreparedStatement를 사용하므로 SQL Injection 위험이 크게 줄어듭니다.
예를 들어 Spring Data JPA나 MyBatis의 파라미터 바인딩은 사용자 입력을 SQL 코드와 분리하여 처리합니다.
하지만 다음과 같이 문자열을 직접 이어 붙여 SQL을 생성하면 여전히 SQL Injection이 발생할 수 있습니다.
String sql =
"SELECT * FROM users WHERE name = '" + name + "'";
따라서 Spring을 사용하더라도 사용자의 입력을 직접 SQL 문자열에 포함해서는 안 됩니다.
실무에서는 다음과 같은 이유로 SQL Injection이 발생하는 경우가 많습니다.
프레임워크는 많은 부분을 보호해 주지만, 안전하지 않은 방식으로 SQL을 작성하면 SQL Injection은 언제든지 발생할 수 있습니다.
이번 탐구를 통해 웹 서비스는 다양한 방식으로 공격받을 수 있다는 사실을 알게 되었습니다. 또한 이러한 공격들이 브라우저, HTTP, 쿠키, 세션, 데이터베이스와 같은 웹의 기본 동작 원리를 악용하여 이루어진다는 점도 이해할 수 있었습니다.
현대에는 Spring과 같은 프레임워크와 다양한 보안 기술의 발전으로 XSS, CSRF, SQL Injection과 같은 대표적인 공격을 효과적으로 방어할 수 있도록 지원하고 있습니다. 하지만 개발자가 보안 원칙을 제대로 이해하지 못하거나 안전하지 않은 방식으로 코드를 작성한다면 여전히 취약점은 발생할 수 있습니다.
이번 탐구를 통해 단순히 공격 기법을 배우는 것을 넘어, 웹의 동작 원리를 이해하고 이를 기반으로 안전한 서비스를 개발하기 위해 보안을 항상 고려해야 한다는 점을 배울 수 있었던 뜻깊은 시간이었습니다.
보안쪽이 확실히 유익한 내용이네요 ^-^