SQL Injection
應用程式把輸入串接進一段 SQL 字串來組出查詢。資料庫收到的是一整條扁平的字串,沒有任何辦法分辨哪些字元是開發者寫的、哪些是隨請求送進來的,於是一個含有 SQL 語法的值就會被當成 SQL 語法解析。資料與語法之間的邊界只存在於兩者分開傳遞的時候;字串串接把這條邊界消掉了。
# 值變成查詢文字的一部分
cur.execute("SELECT * FROM items WHERE id = '" + item_id + "'")
# 值走在查詢旁邊,永遠不進入查詢本身
cur.execute("SELECT * FROM items WHERE id = ?", (item_id,))以下整頁講的是:在看不到原始碼的情況下,從外部辨認出第一種形狀。
payload 的形狀
每一種形狀只問一個問題,而且沒有任何一種能單獨證明什麼。
| 形狀 | 例子 | 它在探什麼 |
|---|---|---|
| 單引號 | ' | 這個值是不是落在一個帶引號的字串常值裡面,以及由此產生的不成對引號會不會讓解析失敗 |
| 成對引號 | '' | 單引號出事的位置換成成對引號會不會被接受。用來區分「解析失敗」與「輸入過濾擋掉了這個字元」 |
| 恆真尾巴 | ' OR '1'='1 | 你補上去的布林條件是被資料庫求值,還是被當成文字比對 |
| 恆假尾巴 | ' AND '1'='2 | 上一列的另一半。一個只會讓資料變多的探測,無法與「過濾壞掉了」區分開來 |
| 註解符號 | -- 後面接一個空白、#、/* | 你的值後面那一段查詢能不能被丟棄。哪一種有效,也順帶縮小了資料庫方言的範圍 |
| 數值欄位裡的算式 | 在 1 合法的地方送 2-1 | 這個值是進到了資料庫會求值的運算式裡,還是先被應用程式轉型掉了 |
| 時間延遲 | 把該方言的 sleep 函式放進布林條件 | 當狀態碼、長度、內容三者都不動時,這條查詢到底有沒有被執行 |
形狀是詞彙,讀懂回答才是技術。
讀回應
一個探測只有對著你自己錄下來的基準線才有意義。
- 用一個平常、合法的值送出請求。記下狀態碼、回應主體的位元組長度、回應時間,以及任何看起來像錯誤訊息的那段文字的逐字內容。
- 同一個請求再送一次,只換掉探測的那一處。方法、路徑、其餘每一個參數、每一個 header 都維持逐位元組相同。
- 拿第二次跟第一次比,不要拿它跟你預期會發生的事比。
什麼都沒改變的探測是一個結果,不是一次失敗的嘗試。
| 可觀察項 | 基準線與探測的差異 | 這個差異代表什麼 | 還有什麼會造成它 |
|---|---|---|---|
| 狀態碼 | 200 變成 500 | 伺服器收下了這個值,然後在處理它的時候失敗。見 HTTP 狀態碼 | 任何未被接住的例外。一個轉型失敗的值根本沒碰到查詢,一樣會回 500 |
| 狀態碼 | 302 變成 200,或反過來 | 應用程式走的分支換了——本來查不到的現在查得到,或反過來 | 取決於 session 狀態而非此參數的重導 |
| 主體長度 | 狀態碼相同,位元組數不同 | 結果集的筆數變了,或錯誤路徑取代了正常內容 | 時間戳、隨機 token,以及任何會把你的輸入原樣回吐的端點。要看差多少,不是看有沒有差 |
| 錯誤文字 | 帶引號的 SQL 片段、驅動程式名稱、unrecognized token、near "..." | 單項最強的訊號,因為它直接說出讀了你的值的那個解析器 | 把每一個例外都印出來的應用程式——包括它自己在查詢之前做驗證時拋的那些 |
| 回應時間 | 只有在注入的條件為真時,才穩定多出好幾秒 | 查詢執行了,而且你的條件控制了它。當狀態碼、長度、主體都不動時,這是唯一剩下的通道 | 冷快取與端點的第一次命中。要重複多次比中位數,絕不看單次讀值 |
| 等價配對 | 兩個在 SQL 求值後相等的值,得到同一份回應 | 求值發生在伺服器端,見下一節 | 配對選對的話,沒有別的原因——這正是它最強的理由 |
等價配對
對同一個參數送兩個值:它們對資料庫來說意思相同,對單純的字串處理來說卻不同。
數值參數上,1 與 2-1 對 SQL 來說是同一個數。把參數轉成整數的應用程式會拒絕 2-1 或在它上面失敗;把它貼進查詢的應用程式則把它交給資料庫,而資料庫算完算式,回傳的正是 1 會回傳的那些列。兩個語法不同的值拿到逐位元組相同的回應,就是「有東西求值了它們」的證據。
字串參數上,配對是「一個常值」對上「同一個常值寫成兩段串接」——例如在 + 代表串接的方言上,用 abc 對上 ab'+'c。串接運算子隨方言而異,所以這裡得到否定結果只排除了一種方言,不是排除了漏洞。
配對要有意義,兩個成員必須在 SQL 求值之後相等、求值之前不相等。若兩者本來就都是合法輸入,回應相同什麼也證明不了。
同一個探測送進兩個參數
這是把「發現」與「巧合」分開的那組對照:同一個探測、兩個參數、兩種答案。
| 你送出什麼 | 不可注入的參數 | 可注入的參數 |
|---|---|---|
在一個合法值後面接一個 ' | 回應與基準線完全相同——狀態碼相同、長度相同、主體相同。那個引號被原樣存起來、被跳脫掉、或被當成一個普通字元拿去比對 | 狀態碼跳到 5xx,或主體多出一段解析器訊息,或主體少掉了「有查到列」時該填的那一塊 |
同一個位置改送 '' | 又跟基準線完全相同。這裡兩種引號都只是普通字元 | 回應回到基準線的形狀。有東西在解析引號,而不是在存它 |
用 ' OR '1'='1 取代合法值 | 與送任何一個錯的值分不出來:同一份「查無資料」的主體、同一個狀態碼 | 與「錯的值」那條基準線不同——通常主體更長,或應用程式走了另一個分支 |
用 ' AND '1'='2 取代合法值 | 一樣與送任何錯的值分不出來 | 與恆真那一版把回應推往相反方向,並且符合「空結果」的形狀 |
| 在合法值後面接註解符號 | 那幾個字元原樣出現在回應裡,或這個值被當成非法輸入退回 | 應用程式接在你的值後面的東西全部失效 |
等價配對 1 與 2-1 | 兩份回應不同,因為只有一個成員是合法輸入 | 兩份回應完全相同 |
| 把 sleep 呼叫放進一個為真的條件 | 回應時間停在基準線上 | 回應時間穩定地多出你要求的那段延遲 |
第一列的份量最重,而它有一個值得寫明的界線:一個什麼都沒改變的單引號,只排除掉吵鬧的那幾種情形。當應用程式自己接住例外、兩種情況都回同一頁時,差異存在於時間那一列,不在長度那一列。
在這個平台上怎麼做
| 事實 | 對探測的意義 |
|---|---|
Code 面板的 requests 送出的每一個請求都會被記進流量記錄,Browser 與 Repeater 的請求也是——三個面板都經同一層派送 | 不論從哪個面板送出,你送過的每一個變體都在同一份清單裡 |
流量表格的欄位是 #、Method、URL、Status、Time;Time 是該次派送的耗時、四捨五入成整數毫秒,所以看到 0ms 是正常的 | Time 欄是一條不必寫程式就有的計時通道。個位數的讀值當成雜訊看待 |
| 點一列會展開成 Request 與 Response 兩個子分頁,顯示重組出來的原始訊息 | 基準線與探測的逐位元組比對就在這裡做 |
| Repeater 分頁只在授權它的題目上出現;沒有授權時,流量記錄裡不會畫出「Send to Repeater」按鈕 | 沒有 Repeater 的題目,就用 Code 面板送變體 |
| Repeater 的 snapshot 只保存請求文字,從不保存回應 | 還原下一個 snapshot 之前先自己把回應記下來,否則比對就沒了 |
傳給 requests 的網址主機名稱會被忽略,只有 path 與 query string 會被使用,而且每個請求都送到目前這道題目的應用程式 | 底下範例裡的主機名稱純屬裝飾。任何探測都離不開這道題目 |
requests 會收下 timeout、verify、proxies、stream、cert 這幾個參數,然後完全不使用它們 | Python 這一側的 timeout 擋不住一個慢請求。你這邊沒有任何東西會把請求切斷 |
題目請求超過 runtime 的十秒上限時,會拿到狀態碼 508 與 {"error": "challenge runtime execution exceeded the time limit"},接著頁面上方出現橫幅,說明 runtime 已被重啟、這道題目裡的登入狀態與上傳的檔案都不見了 | 要求的延遲要壓在十秒以下一段距離。延遲一旦超過,會毀掉你正拿來測試的那份 session 狀態 |
學習者的程式不會因為跑久了就被平台切斷——列舉與掃描在這裡是正常工作。唯一的上限是單次執行六小時,超過之後輸出區顯示 the runner stopped responding | 一個跑過大量變體的迴圈可以放它自己跑完。只有以小時計的掃描才碰得到這個上限 |
回應物件帶有 status_code、headers、content、text、url、request、raw,其中 text 一律以 UTF-8 解碼 | r.elapsed 由 Session.send 填好,量的是整趟 dispatch 來回,所以計時不必另外抓時鐘。raw 是個替身,它的 read() 一律回空 bytes、也沒有 stream(),所以 stream=True 與 iter_content 讀不到東西——主體要從 .text 或 .content 取 |
requests 是用 micropip 裝進來的真正函式庫,而 micropip 也留著可以再裝其他純 Python 套件 | 一般的 requests 用法都適用,包含 Session |
面板怎麼開、腳本怎麼執行、怎麼中止,見 Code 面板。腳本完全沒有輸出時,見疑難排解。
用腳本比對變體
方法是:一個參數、一條基準線、一份變體清單,最後報出哪些變體讓回應動了。
import time
import requests
TARGET = "http://challenge.local" # 主機名稱會被忽略,只有路徑會被使用
ENDPOINT = "/api/items"
PARAM = "id"
BASELINE = "1"
VARIANTS = [
("lone quote", BASELINE + "'"),
("doubled quote", BASELINE + "''"),
("always true", BASELINE + "' OR '1'='1"),
("always false", BASELINE + "' AND '1'='2"),
("comment tail", BASELINE + "' -- "),
("equivalent pair", "2-1"),
("control value", "9999"),
]
def probe(value):
start = time.perf_counter()
r = requests.get(TARGET + ENDPOINT, params={PARAM: value})
return r.status_code, len(r.content), round((time.perf_counter() - start) * 1000), r.text
base_status, base_len, base_ms, base_body = probe(BASELINE)
print(f"baseline {base_status} {base_len:>6} bytes {base_ms:>5} ms")
for label, value in VARIANTS:
status, length, ms, body = probe(value)
diffs = []
if status != base_status:
diffs.append(f"status {base_status}->{status}")
if length != base_len:
diffs.append(f"length {length - base_len:+d} bytes")
if ms > base_ms * 3 + 200:
diffs.append(f"time {base_ms}->{ms} ms")
body_note = "body identical" if body == base_body else "body differs"
print(f"{label:16} {status:>3} {length:>6} bytes {ms:>5} ms {body_note:14} {'; '.join(diffs) or 'no other difference'}")control value 那一列是這份報告能讀的原因:它是一個看起來合法、卻不帶任何 SQL 語法的值,所以它顯示出來的差異就是這個端點在兩個不同輸入之間本來就有的變動量。任何變體的差異若不大於 control 的差異,它就不是任何東西的證據。
腳本只把變體分成「讓回應動了」與「沒動」兩堆,它不做判斷。相信任何一列之前,先在流量記錄裡把那兩筆一筆一筆展開,在 Response 子分頁分別讀過兩份主體——面板一次只留一列展開,開第二筆會收合第一筆。一個會把輸入原樣回吐的端點上,那幾個位元組的長度差就是你的輸入本身,不是查詢。
什麼時候可以說這個參數確實可注入
以下條件必須同時成立:
- 一個帶 SQL 語法的探測讓回應與基準線不同,而且這個差異在重複送出時可以重現。
- 差異跟著注入的 SQL 的語意走,而不是跟著它的存在與否走:同一個探測的恆真版與恆假版,把回應推往相反的兩個方向。
- 把語法補成成對之後回應回到基準線——成對引號,或用註解符號丟掉查詢的其餘部分,都讓你回到出發時的形狀。
- 一個長度與字元組成相近、但不帶任何 SQL 語法的對照值,讓回應停在基準線上。這一條排除掉的是「這個端點本來就脆」。
以上任何一條都不能單獨成立,而且有四種情形明確不足:
- 只有一個
500。 每一個未被接住的例外都會產生它,包含在查詢被組出來之前就失敗的整數轉型。 - 任何只看過一次的觀察。 送一次就是一個樣本;無法重現的差異不是差異。
- 一次比較慢的回應。 計時證據是一組配對——同一個探測的條件為真與為假各測多次。單一次的慢讀值是冷快取。
- 一段指名資料庫的 stack trace。 它告訴你應用程式後面是哪套資料庫,沒有告訴你你的值抵達了查詢。
上述所有通道都不動的時候,這個參數並沒有被排除,只是未被證實。剩下的通道是計時,而計時結果扛的責任跟其他通道一樣:要一組可重現的配對,不是一次讀值。