Contents

Neurogrid CTF: Human-Only 2025 | Secure Coding

Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo as “Legendary Queue” and finished in the top 4 out of 1337 players. This post covers the Secure Coding challenges.

Neurogrid CTF: Human-Only 2025

Team Solves Progress


Description: The Shugo no Michi bus ticketing system has a multi-service architecture. The C++ data parser component has some memory issues that need addressing.

Points: 1000 | Difficulty: Medium |Solves: 3

This was the hardest challenge in the secure coding category - only 3 players solved it during the competition. The multi-service architecture and strict static analyzer made this particularly tricky.

A complex application with four services: Rails web app, C++ data parser, Python logic tracker, and PostgreSQL. The vulnerability is in the C++ parser (data_parser/src/main.cpp).

The struct definition uses a fixed-size buffer:

struct TicketRow {
  char name_buf[200];           // Fixed-size buffer
  std::string bus_code;
  std::string user_email;
  std::string travel_date;
  int         seats       = 1;
  int         start_node  = 0;
  int         end_node    = 0;
  long long   total_cents = 0;
};

When fetching from PostgreSQL, the name is copied without bounds checking:

const char* name = PQgetvalue(res, i, 0);
std::strcpy(tr.name_buf, name);  // Buffer overflow if name > 200 chars

If the database contains a name longer than 200 characters, this overflows into adjacent struct members, corrupting memory.

Insert a ticket with a name longer than 200 characters into the database. When the parser fetches it, the strcpy overflows, potentially crashing the service or enabling code execution.

Replace all C-style char buffers with std::string. The static analyzer was strict - it rejected the code if ANY char[] buffers remained.

Fix 1: Replace the struct member:

struct TicketRow {
  std::string name;             // Safe: dynamic allocation
  std::string bus_code;
  // ...
};

Fix 2: Replace strcpy with assignment:

// Before
std::strcpy(tr.name_buf, name);

// After
tr.name = PQgetvalue(res, i, 0);

Fix 3: Update JSON serialization:

// Before
<< "\"name\":\"" << jesc(r.name_buf) << "\","

// After
<< "\"name\":\"" << jesc(r.name) << "\","

Fix 4: Replace other char buffers too:

// Unicode escape buffer - before
char buf[7]; std::snprintf(buf, sizeof(buf), "\\u%04x", c);

// After
std::ostringstream esc;
esc << "\\u" << std::hex << std::setfill('0') << std::setw(4) << static_cast<int>(c);
out += esc.str();

// Network receive buffer - before
char buf[256]; ::recv(c, buf, sizeof(buf), 0);

// After
std::string recv_buf(256, '\0');
::recv(c, recv_buf.data(), recv_buf.size(), 0);

The Rails web app had a mass assignment vulnerability in registration. Looking at users_controller.rb:

def user_params
  base = [:email, :password, :password_confirmation]
  extras = (request.format.json? || request.headers['Accept'].to_s.include?('json')) ? [:role] : []
  params.require(:user).permit(*(base + extras))
end

JSON requests get :role added to permitted params. An attacker can register as admin:

curl -X POST http://target/users \
  -H "Content-Type: application/json" \
  -d '{"user":{"email":"attacker@test.com","password":"password123","password_confirmation":"password123","role":"admin"}}'

The fix removes :role from permitted params entirely and adds a model callback to enforce the default:

# Controller - never permit role
def user_params
  params.require(:user).permit(:email, :password, :password_confirmation)
end

# Model - force default role
before_validation :set_default_role
def set_default_role
  self.role = 'user'
end

The pricing calculation was broken due to inconsistent grid dimensions across files:

File Constant Value
ticket.rb GRID_COLS 12 (wrong)
tickets_controller.rb GRID_COLS 28 (correct)
logic_tracker_client.rb DEFAULT_COLS 12 (wrong)
map_controller.rb cols 28 (correct)

The map is 28 columns wide, but some files used 12. This caused incorrect node-to-coordinate conversions, resulting in wrong pricing calculations for routes.

The fix standardizes all files to use GRID_COLS = 28:

# In ticket.rb
GRID_COLS = 28

# In logic_tracker_client.rb
DEFAULT_COLS = 28

Flag: HTB{7H3_8U773RFLY_3FF3C7_W0RK5_3V3RYWH3R3!!}


Description: Deep in Kurozan’s archives, a printing press processes sealed orders. Requests arrive at a clerk’s desk, are inspected and stored, then queued for backend artisans working in a separate chamber. The clerk washes away suspicious characters before storage, but Kenji wonders: does the artisan re-inspect what emerges, or simply trust the archive? And can anyone slip into the unlocked workshop to tamper with the queue directly?

Points: 975 | Difficulty: Medium

A Python Flask application split into frontend and backend services communicating via Redis. Looking at the architecture, the frontend serializes job data and pushes it to Redis, where the backend picks it up and deserializes it.

The frontend in frontend/app.py uses pickle for serialization:

benign = {
    "name": filename,
    "submitted_by": session.get("user"),
    "note": "standard print",
    "uploaded_path": str(save_path),
    "submitted_at": datetime.now(timezone.utc).isoformat(),
}
data_b64 = base64.b64encode(pickle.dumps(benign, protocol=pickle.HIGHEST_PROTOCOL)).decode()
enqueue_job(job_id, data_b64)

The backend blindly deserializes whatever comes through:

def _unpickle(b64_data: str) -> Any:
    decoded = base64.b64decode(b64_data)
    return pickle.loads(decoded)  # RCE here

The challenge attempted to mitigate this with a suspicious_pickle() function - 67 lines of opcode analysis trying to blocklist dangerous modules like os, subprocess, sys, etc. But pickle is fundamentally unsafe for untrusted data. The provided exploit.py demonstrates the attack:

# Pickle payload that executes os.system()
final_payload = f"(S'{command}'\nios\nsystem\n.".encode()

The blocklist approach is flawed because pickle has too many ways to achieve code execution:

  • Different opcodes (INST, GLOBAL, REDUCE)
  • Alternative module paths
  • Gadget chains through standard library classes

The exploit bypasses the blocklist entirely.

The data being serialized is just strings and timestamps - no need for pickle’s power. Replace it with JSON:

Frontend (frontend/app.py):

# Before: pickle serialization
data_b64 = base64.b64encode(pickle.dumps(benign, protocol=pickle.HIGHEST_PROTOCOL)).decode()

# After: JSON serialization
import json
data_b64 = base64.b64encode(json.dumps(benign).encode()).decode()

Backend (backend/app.py):

# Before: pickle deserialization
def _unpickle(b64_data: str) -> Any:
    decoded = base64.b64decode(b64_data)
    return pickle.loads(decoded)

# After: JSON deserialization
def _unpickle(b64_data: str) -> Any:
    decoded = base64.b64decode(b64_data)
    return json.loads(decoded.decode('utf-8'))

Remove the 67-line suspicious_pickle() function entirely and replace with simple JSON validation:

try:
    data = json.loads(raw.decode('utf-8'))
    if not isinstance(data, dict):
        return jsonify({"error": "payload must be a JSON object"}), 400
except (json.JSONDecodeError, UnicodeDecodeError):
    return jsonify({"error": "payload must be valid JSON"}), 400

JSON only supports basic types (strings, numbers, dicts, lists, booleans, null) - no way to inject code.

Flag: HTB{7H3_7RU3_9U1D3_70_7H3_P1CKL3_W0RLD}


Description: Odayaka Waters is an internal chat application. Something about the registration flow seems off.

Points: 925 | Difficulty: Easy

A Laravel 12 application with role-based access control. The admin endpoints are protected by middleware checking $user->role. Looking at the registration controller:

public function register(Request $request)
{
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        return redirect()->route('register');
    }

    if (count($_POST) !== 4) {
        return redirect()->route('register')
            ->with('error', 'Ensure you only have the name, email and password parameter!');
    }

    $user = User::create([
        'name'     => $_REQUEST['name'],
        'email'    => $_REQUEST['email'],
        'password' => Hash::make($_REQUEST['password']),
        'role'     => $_REQUEST['role'] ?? 'user',  // VULNERABLE
    ]);
}

The developer tried to prevent extra parameters by checking count($_POST) !== 4, but reads input from $_REQUEST which includes GET parameters. So passing role=admin via GET while sending other fields via POST bypasses the check.

The User model has 'role' in $fillable:

protected $fillable = ['name','email','password','role'];
POST /register?role=admin HTTP/1.1
Content-Type: application/x-www-form-urlencoded

name=attacker&email=attacker@test.com&password=password123&_token=XXX

The $_POST count is 4 (name, email, password, _token), but $_REQUEST['role'] reads admin from the query string. User created with admin privileges.

Hardcode the role and use Laravel’s validation instead of superglobals:

public function register(Request $request)
{
    $validated = $request->validate([
        'name'     => ['required', 'string', 'max:255'],
        'email'    => ['required', 'email', 'max:255', 'unique:users'],
        'password' => ['required', 'string', 'min:8'],
    ]);

    $user = User::create([
        'name'     => $validated['name'],
        'email'    => $validated['email'],
        'password' => Hash::make($validated['password']),
        'role'     => 'user',  // Hardcoded - no user control
    ]);

    Auth::login($user);
    $request->session()->regenerate();
    return redirect()->intended('/waters');
}

Also remove 'role' from the model’s $fillable array for defense in depth:

protected $fillable = ['name','email','password'];

The fix doesn’t try to validate the role input - it simply doesn’t accept it. Authorization decisions should never depend on user-controllable data.

Flag: HTB{CLARITY_IS_THE_KEY_TO_CONFUSION}


Description: Sakura’s Embrace is a Japanese specialty shop. The cart supports mathematical expressions for quantities. The sanitization seems thorough, but is it?

Points: 975 | Difficulty: Very Easy

An Express.js e-commerce application that evaluates mathematical expressions for cart quantities (allowing inputs like “2+3” or “5*2”). The vulnerable code:

function sanitizeExpression(expr) {
  let s = expr.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
  const forbidden = /\b(require|child_process|fs|vm|import|constructor\.constructor|Function)\b/gi;
  if (forbidden.test(s)) throw new Error("Forbidden token detected");
  if (/[;{}]/.test(s)) throw new Error("Illegal punctuation");
  return s.trim().slice(0, 4096);
}

function _eval(expr) {
  const cleaned = sanitizeExpression(String(expr));
  return eval(cleaned);  // RCE
}

The regex uses word boundaries (\b), which can be bypassed with string concatenation.

// Blocked: fs is a word
process.getBuiltinModule('fs')

// Bypasses word boundary: 'f'+'s' is not the word "fs"
process.getBuiltinModule('f'+'s').readFileSync('/flag.txt','utf8')
curl -X POST http://target/cart/add \
  -d "itemId=1" \
  -d "quantity=process.getBuiltinModule('f'%2B's').readFileSync('/flag.txt','utf8')"

The package.json already includes mathjs - a hint about the intended solution. Replace eval() with a hardened mathjs instance:

import { create, all } from 'mathjs';

const math = create(all);

// Disable dangerous functions
math.import({
  'import': function () { throw new Error('Function import is disabled') },
  'createUnit': function () { throw new Error('Function createUnit is disabled') },
  'reviver': function () { throw new Error('Function reviver is disabled') }
}, { override: true });

function safeEval(expr) {
  try {
    const result = math.evaluate(String(expr).trim().slice(0, 4096));
    if (typeof result === 'number' && Number.isFinite(result)) {
      return result;
    }
    return NaN;
  } catch {
    return NaN;
  }
}

Replace all three calls from _eval to safeEval:

  • Line 75: qtyNum = safeEval(\(${qtyExprRaw})`);`
  • Line 76: lineTotal = safeEval(lineExpr);
  • Line 83: total = safeEval(formula);

The mathjs library provides a sandboxed environment with only mathematical operations - no access to process, require, or the filesystem. Disabling import, createUnit, and reviver addresses known security concerns from the mathjs documentation.

Just using mathjs wasn’t enough - the verifier required the dangerous functions to be explicitly disabled. Reading the library’s security documentation was essential.

Flag: HTB{N07_4LL_FL0W3R5_4R3_834U71FUL}

Related Content