개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › 보안

입력과 인젝션

사용자가 입력한 글이 코드로 읽히면 무슨 일이 생기는가

SQL은 DB에 글자로 보내는 명령입니다. 사용자가 입력창에 쓴 이름으로 회원을 찾으려면 그 이름이 SQL 어딘가에 들어가야 하는데, 자바 코드에는 방법이 두 가지 있습니다. 하나는 SQL 글자에 입력을 이어 붙이는 것("… name = '" + input + "'")이고, 하나는 SQL에 ?로 값 자리를 비워 두고 입력은 PreparedStatement(값을 따로 받는 도구)로 따로 넘기는 것입니다. 이어 붙이면 입력 안에 든 따옴표 같은 글자도 SQL 글자의 일부가 되어서, DB가 그것을 SQL 문법으로 읽습니다. 그러면 입력이 값이 아니라 명령이 됩니다. 이것이 SQL 인젝션입니다. ?로 넘기면 입력 안의 따옴표나 OR도 찾을 값의 일부일 뿐입니다. 이 실습은 두 방법에 평범한 이름, 따옴표와 OR가 든 글, 따옴표가 든 평범한 이름을 하나씩 넣어, DB가 받은 SQL과 돌아온 행을 봅니다. (화면의 코드는 main 메서드 안에 있다고 보고 import 줄과 main 선언은 줄였습니다. 입력창은 만들지 않고 input 변수에 글을 적어 두었습니다. 웹 공격 전반과 JdbcTemplate은 세부 동작 실습에서 다룹니다.)

실행으로 대조 · JDK 21과 H2 2.4.240에서 여섯 가지 조합을 컴파일해 새 자바 프로세스로 실행해 대조, 규칙은 OWASP와 Java SE 21 API 문서와 대조

이 실습에 나오는 개념

Statement와 executeQuery
Statement는 값을 끼워 넣지 않은 SQL 문장을 DB에 보내 실행하는 데 쓰는 객체이고, conn.createStatement()로 만든다. executeQuery(sql)은 SELECT 같은 문장을 보내고 결과를 ResultSet 하나로 돌려주며, 돌려주는 값이 null인 일은 없다. DB 접근 오류가 나거나 닫힌 Statement에서 부르거나 ResultSet 하나를 만들지 않는 SQL을 보내면 SQLException이 난다. SQL은 자바에게 그냥 문자열이어서 글자가 틀려도 컴파일은 되고, DB가 읽는 실행 때에 오류로 알려진다. Statement를 만드는 것만으로는 SQL이 DB에 가지 않고 executeQuery를 부를 때 보내지는 것은 H2에서 실행해 확인했다.
PreparedStatement와 바인드 파라미터
PreparedStatement는 SQL 문장을 미리 컴파일해 두고 같은 문장을 여러 번 실행하는 데 쓰는 객체이고, conn.prepareStatement(sql)로 만든다. SQL에서 값이 들어갈 자리를 ?로 비워 두고, setString(1, 값)처럼 자리 번호(1부터 시작)와 값을 따로 넘긴다. 이렇게 SQL을 먼저 정하고 값을 나중에 넘기는 값을 바인드 파라미터라고 한다. OWASP는 이 방식에서 DB가 입력이 무엇이든 코드와 데이터를 항상 구별하므로, 공격자가 SQL 명령을 끼워 넣어도 질의의 의도를 바꿀 수 없다고 설명한다. 이 문서의 H2 2.4.240 실행에서도 값을 ?로 넘기면 DB가 기록한 SQL에는 ?가 그대로 남고 입력의 글은 나타나지 않았다.
SQL 문자열 값과 따옴표
SQL에서 글자 값(문자열 리터럴)은 작은따옴표로 시작해서 작은따옴표로 끝낸다. 문자열 안에 작은따옴표 글자를 넣으려면 H2에서는 따옴표를 두 번 써서 ''로 적고(예: 'John''s car'), MySQL은 ''와 함께 앞에 \를 붙이는 방법도 허용한다. 따옴표가 문자열의 끝을 알리는 글자이기도 해서, 입력을 SQL 글자에 그대로 이어 붙이면 입력 안의 따옴표가 문자열을 끝내 버리고 그 뒤의 글자가 SQL 문법으로 읽힌다. 따옴표가 든 평범한 이름(O'Neil)도 이어 붙이면 문법 오류가 나는 이유가 이것이다. 값으로 넘기는 방식은 따옴표를 SQL 글자에 쓰지 않아 이 문제가 없다.
SQL Injection
사용자 입력이 SQL 문의 일부로 해석되어 공격자가 의도한 쿼리가 실행되는 공격이다. 입력값을 문자열로 이어 붙여 SQL을 만들 때 발생한다. SQL 구조와 값을 분리하는 바인드 파라미터(PreparedStatement)를 쓰면 입력이 항상 값으로만 처리되어 막을 수 있다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. String input = "kim": input 변수에 "kim"라는 글이 담깁니다. 평범한 이름입니다. 이 실습에서는 이것이 사용자가 입력창에 쓴 글이라고 보고, 입력창 대신 코드에 글을 적어 두었습니다. DB의 member 테이블에는 kim, lee, O'Neil 세 행이 들어 있습니다.
  2. String sql = "SELECT name FROM member WHERE name = ?": sql 변수에 SQL 글자를 담습니다. 값이 들어갈 자리는 ?로 비어 있고, input의 글은 이 글자에 들어가지 않습니다. 아직 DB에는 아무것도 보내지 않았습니다.
  3. conn.prepareStatement(sql): sql 글자를 가진 PreparedStatement 객체를 만들어 ps에 담습니다. ps가 가진 SQL에는 ? 자리가 1개 있고 값은 아직 없습니다. DB의 실행 기록에도 이 SELECT는 아직 없습니다.
  4. ps.setString(1, input): 1번 ? 자리에 input의 글을 값으로 넘깁니다. ps는 SQL 글자(? 자리가 있는 그대로)와 1번 자리의 값을 따로 가집니다. input의 글은 SQL 글자에 끼워 넣어지지 않습니다.
  5. ps.executeQuery(): ps는 값을 SQL 글과 따로 다뤄서, 값이 SQL 문법으로 읽히지 않게 합니다. DB가 실행한 SQL은 "SELECT name FROM member WHERE name = ?"이고, 이 글자 어디에도 input의 글은 없습니다. DB는 member 테이블에서 name이 값과 같은 행을 찾습니다. name이 "kim"인 행은 1개입니다. 자바는 결과를 ResultSet rs로 받습니다.
  6. while (rs.next()): rs의 행을 하나씩 읽어 name을 출력합니다. 출력은 kim입니다.

실행 결과

코드

// member 테이블에는 세 행(name: kim, lee, O'Neil)이 들어 있다
// 연결: 주소가 가리키는 DB와 연결을 맺는다
Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop");
// 입력: 사용자가 입력창에 쓴 글이라고 하자
String input = "kim";
// SQL: 값이 들어갈 자리를 ?로 비워 둔다
String sql = "SELECT name FROM member WHERE name = ?";
// 보내기: SQL 글자와 값을 따로 넘긴다
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, input);
ResultSet rs = ps.executeQuery();
// 읽기: 돌아온 행마다 name을 출력한다
while (rs.next()) {
    System.out.println(rs.getString("name"));
}

이 모형의 전제

JDK 21.0.12.1과 H2 2.4.240(메모리 DB)에서 실행해 대조했고, MySQL에서의 실행 대조는 하지 않았습니다. 여섯 가지 조합을 모두, 화면의 코드를 main 메서드가 든 Main.java에 넣어(import java.sql.*와 main 선언만 더했습니다) javax.tools로 컴파일하고, 조합마다 새 자바 프로세스에서 실행했습니다. 실행 전에 같은 프로세스에서 member 테이블(id INT 기본 키, name VARCHAR(20))과 세 행 (1, kim), (2, lee), (3, O'Neil)을 만들어 두었는데, 화면에서는 이 준비 코드를 줄였습니다. 입력창은 만들지 않고 input 변수에 글을 적어 두었습니다. 대조한 것은 결과 표의 세 항목입니다. DB가 받은 SQL은 H2의 INFORMATION_SCHEMA.QUERY_STATISTICS(실행 기록)에 남은 SELECT이고, 문법 오류로 실행되지 못한 조합은 오류가 DB 엔진의 파서에서 났고 오류 메시지가 그 SQL을 인용한 것으로 판정했습니다(실행 기록은 비어 있었습니다). 돌아온 행은 코드가 출력한 name이고, 입력이 다뤄진 방식은 실제 값에서 얻었습니다. DB가 받은 SQL에 input의 글이 들어 있으면 "SQL 글자의 일부", 들어 있지 않으면 "값으로만 다뤄짐"이고, 괄호 안은 오류가 났는지, 이름이 input과 같은 행보다 많은 행이 돌아왔는지로 정했습니다. 결과 표의 첫 항목에서 ?로 넘긴 조합의 값은 코드에서 setString으로 넘긴 글입니다. 이 값이 ps에 SQL 글자와 따로 들어 있는 것은 H2가 ps를 문자열로 보여 주는 모양(SQL 글자 뒤에 {1: 값})으로 확인했습니다. 이 모양에서 값 안의 작은따옴표가 두 번 쓰이는 것은 H2가 값을 SQL 글자 값으로 보여 주는 표기이고, 값이 SQL 글자에 끼워졌다는 뜻이 아닙니다. 단계 문장이 기대는 사실로, prepareStatement와 setString까지 한 시점과 createStatement까지 한 시점에는 DB의 실행 기록에 이 SELECT가 없는 것, ps의 ? 자리가 1개이고 이어 붙인 SQL의 ? 자리는 0개인 것도 실행으로 확인했습니다(실행 기록은 한 번 읽으면 새 기록이 보이지 않아 시점마다 DB를 따로 만들어 한 번씩만 읽었습니다). O'Neil을 이어 붙인 조합의 오류는 H2의 SQLState 42000(문법 오류)이고, 메시지의 오류 위치는 Neil 바로 뒤였으며 오류가 난 곳은 H2의 문자열 읽기(Tokenizer.readCharacterString)였습니다. 화면의 설명("두 번째 따옴표가 문자열을 끝내고 마지막 따옴표는 닫히지 않은 문자열을 연다")은 이 관찰과 문자열 안의 따옴표를 두 번 써야 한다는 H2 문서에 맞춘 풀이이고, H2가 글자를 어떻게 나누는지는 직접 추적하지 않았습니다. 이 오류 코드는 H2의 것이어서 MySQL에서는 다를 수 있습니다. 실제 DB 로그에 SQL 글자가 어떻게 남는지와 결과 행의 순서(ORDER BY가 없어 DB가 정하는 순서)는 H2에서 본 것일 뿐입니다. 문서는 직접 열어 확인했습니다. OWASP SQL Injection Prevention Cheat Sheet에서 문자열 이어 붙이기와 사용자 입력으로 만든 질의가 위험하다는 것(What Is a SQL Injection Attack?), 준비된 문장에서는 SQL을 먼저 정하고 값을 나중에 넘기며 DB가 입력이 무엇이든 코드와 데이터를 구별한다는 것(Defense Option 1), Java 예제가 SELECT에 ?를 쓰고 setString으로 값을 넘기며 tom' or '1'='1 같은 입력도 그 글 전체와 같은 이름을 찾을 뿐이라는 것(Safe Java Prepared Statement Example)을 따랐습니다. Java SE 21 API 문서의 PreparedStatement에서는 미리 컴파일된 SQL 문장이라는 설명, 값을 넣는 setter가 ? 자리(IN 파라미터)의 값을 정한다는 것, 자리 번호가 1부터라는 것을, Statement에서는 정해진(static) SQL 문장을 실행하는 객체이고 executeQuery(String)이 ResultSet 하나를 돌려준다는 것을 확인했습니다. PreparedStatement 문서는 setString이 따옴표를 어떻게 다루는지는 적고 있지 않아서, 값이 따옴표와 상관없이 다뤄지는 것은 문서가 정한 것이 아니라 이 H2에서 관찰한 동작입니다. H2 문서 grammar 페이지에서 문자열 값이 작은따옴표로 시작하고 끝나며 안에 작은따옴표를 넣으려면 두 번 쓴다는 것을, MySQL 8.4 문서의 String Literals에서 같은 규칙(MySQL은 \를 앞에 붙이는 방법도 허용)을 확인했습니다. 열린 문서는 모두 도구가 돌려준 요약으로 읽었고 원문 전체를 읽지는 않았습니다. 화면의 코드는 짧게 하려고 연결과 ResultSet을 닫는 줄과 예외를 처리하는 코드를 적지 않았고, 예외가 난 줄에서 프로그램이 멈추고 아래 줄이 실행되지 않는 것은 출력이 비어 있는 것으로 확인했습니다. 이 실습은 SQL 인젝션 하나만 다루며, 입력 검사, 출력할 때의 이스케이프, DB 계정 권한, 실제 공격에서 쓰는 다른 수법, 입력이 SQL이 아닌 곳(HTML, 명령어 등)에서 코드로 읽히는 경우는 다루지 않습니다. 웹 공격 전반(XSS, CSRF 포함)은 "웹 공격 세 가지", SQL을 직접 쓰는 JDBC의 반복되는 코드를 줄여 주는 JdbcTemplate은 "JdbcTemplate과 예외 변환"에서 다룹니다.

앞 실습: 출처(origin) · 다음 실습: 프로세스

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요