Neurogrid CTF: Human-Only 2025 | Secure Coding
Secure Coding challenges from Neurogrid CTF: Human-Only 2025
Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo and finished in the top 4 out of 1,337 players. This post covers the Secure Coding challenges.


Shugo No Michi’s System [medium]
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.
Analysis
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:
1struct TicketRow {
2 char name_buf[200]; // Fixed-size buffer
3 std::string bus_code;
4 std::string user_email;
5 std::string travel_date;
6 int seats = 1;
7 int start_node = 0;
8 int end_node = 0;
9 long long total_cents = 0;
10};When fetching from PostgreSQL, the name is copied without bounds checking:
1const char* name = PQgetvalue(res, i, 0);
2std::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.
Exploitation
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.
The Fix
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:
1struct TicketRow {
2 std::string name; // Safe: dynamic allocation
3 std::string bus_code;
4 // ...
5};Fix 2: Replace strcpy with assignment:
1// Before
2std::strcpy(tr.name_buf, name);
3
4// After
5tr.name = PQgetvalue(res, i, 0);Fix 3: Update JSON serialization:
1// Before
2<< "\"name\":\"" << jesc(r.name_buf) << "\","
3
4// After
5<< "\"name\":\"" << jesc(r.name) << "\","Fix 4: Replace other char buffers too:
1// Unicode escape buffer - before
2char buf[7]; std::snprintf(buf, sizeof(buf), "\\u%04x", c);
3
4// After
5std::ostringstream esc;
6esc << "\\u" << std::hex << std::setfill('0') << std::setw(4) << static_cast<int>(c);
7out += esc.str();
8
9// Network receive buffer - before
10char buf[256]; ::recv(c, buf, sizeof(buf), 0);
11
12// After
13std::string recv_buf(256, '\0');
14::recv(c, recv_buf.data(), recv_buf.size(), 0);Vulnerability 2: Authentication Bypass
The Rails web app had a mass assignment vulnerability in registration. Looking at users_controller.rb:
1def user_params
2 base = [:email, :password, :password_confirmation]
3 extras = (request.format.json? || request.headers['Accept'].to_s.include?('json')) ? [:role] : []
4 params.require(:user).permit(*(base + extras))
5endJSON requests get :role added to permitted params. An attacker can register as admin:
1curl -X POST http://target/users \
2 -H "Content-Type: application/json" \
3 -d '{"user":{"email":"[email protected]","password":"password123","password_confirmation":"password123","role":"admin"}}'The fix removes :role from permitted params entirely and adds a model callback to enforce the default:
1# Controller - never permit role
2def user_params
3 params.require(:user).permit(:email, :password, :password_confirmation)
4end
5
6# Model - force default role
7before_validation :set_default_role
8def set_default_role
9 self.role = 'user'
10endVulnerability 3: Pricing Calculation Error
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:
1# In ticket.rb
2GRID_COLS = 28
3
4# In logic_tracker_client.rb
5DEFAULT_COLS = 28Flag
Flag:
HTB{7H3_8U773RFLY_3FF3C7_W0RK5_3V3RYWH3R3!!}
Yugen’s Choice [medium]
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
Analysis
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:
1benign = {
2 "name": filename,
3 "submitted_by": session.get("user"),
4 "note": "standard print",
5 "uploaded_path": str(save_path),
6 "submitted_at": datetime.now(timezone.utc).isoformat(),
7}
8data_b64 = base64.b64encode(pickle.dumps(benign, protocol=pickle.HIGHEST_PROTOCOL)).decode()
9enqueue_job(job_id, data_b64)The backend blindly deserializes whatever comes through:
1def _unpickle(b64_data: str) -> Any:
2 decoded = base64.b64decode(b64_data)
3 return pickle.loads(decoded) # RCE hereThe 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:
1# Pickle payload that executes os.system()
2final_payload = f"(S'{command}'\nios\nsystem\n.".encode()Exploitation
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 Fix
The data being serialized is just strings and timestamps - no need for pickle’s power. Replace it with JSON:
Frontend (frontend/app.py):
1# Before: pickle serialization
2data_b64 = base64.b64encode(pickle.dumps(benign, protocol=pickle.HIGHEST_PROTOCOL)).decode()
3
4# After: JSON serialization
5import json
6data_b64 = base64.b64encode(json.dumps(benign).encode()).decode()Backend (backend/app.py):
1# Before: pickle deserialization
2def _unpickle(b64_data: str) -> Any:
3 decoded = base64.b64decode(b64_data)
4 return pickle.loads(decoded)
5
6# After: JSON deserialization
7def _unpickle(b64_data: str) -> Any:
8 decoded = base64.b64decode(b64_data)
9 return json.loads(decoded.decode('utf-8'))Remove the 67-line suspicious_pickle() function entirely and replace with simple JSON validation:
1try:
2 data = json.loads(raw.decode('utf-8'))
3 if not isinstance(data, dict):
4 return jsonify({"error": "payload must be a JSON object"}), 400
5except (json.JSONDecodeError, UnicodeDecodeError):
6 return jsonify({"error": "payload must be valid JSON"}), 400JSON only supports basic types (strings, numbers, dicts, lists, booleans, null) - no way to inject code.
Flag
Flag:
HTB{7H3_7RU3_9U1D3_70_7H3_P1CKL3_W0RLD}
Odayaka Waters [easy]
Description: Odayaka Waters is an internal chat application. Something about the registration flow seems off.
Points: 925 | Difficulty: Easy
Analysis
A Laravel 12 application with role-based access control. The admin endpoints are protected by middleware checking $user->role. Looking at the registration controller:
1public function register(Request $request)
2{
3 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
4 return redirect()->route('register');
5 }
6
7 if (count($_POST) !== 4) {
8 return redirect()->route('register')
9 ->with('error', 'Ensure you only have the name, email and password parameter!');
10 }
11
12 $user = User::create([
13 'name' => $_REQUEST['name'],
14 'email' => $_REQUEST['email'],
15 'password' => Hash::make($_REQUEST['password']),
16 'role' => $_REQUEST['role'] ?? 'user', // VULNERABLE
17 ]);
18}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:
1protected $fillable = ['name','email','password','role'];Exploitation
1POST /register?role=admin HTTP/1.1
2Content-Type: application/x-www-form-urlencoded
3
4name=attacker&email=[email protected]&password=password123&_token=XXXThe $_POST count is 4 (name, email, password, _token), but $_REQUEST['role'] reads admin from the query string. User created with admin privileges.
The Fix
Hardcode the role and use Laravel’s validation instead of superglobals:
1public function register(Request $request)
2{
3 $validated = $request->validate([
4 'name' => ['required', 'string', 'max:255'],
5 'email' => ['required', 'email', 'max:255', 'unique:users'],
6 'password' => ['required', 'string', 'min:8'],
7 ]);
8
9 $user = User::create([
10 'name' => $validated['name'],
11 'email' => $validated['email'],
12 'password' => Hash::make($validated['password']),
13 'role' => 'user', // Hardcoded - no user control
14 ]);
15
16 Auth::login($user);
17 $request->session()->regenerate();
18 return redirect()->intended('/waters');
19}Also remove 'role' from the model’s $fillable array for defense in depth:
1protected $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
Flag:
HTB{CLARITY_IS_THE_KEY_TO_CONFUSION}
Sakura’s Embrace [very easy]
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
Analysis
An Express.js e-commerce application that evaluates mathematical expressions for cart quantities (allowing inputs like “2+3” or “5*2”). The vulnerable code:
1function sanitizeExpression(expr) {
2 let s = expr.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
3 const forbidden = /\b(require|child_process|fs|vm|import|constructor\.constructor|Function)\b/gi;
4 if (forbidden.test(s)) throw new Error("Forbidden token detected");
5 if (/[;{}]/.test(s)) throw new Error("Illegal punctuation");
6 return s.trim().slice(0, 4096);
7}
8
9function _eval(expr) {
10 const cleaned = sanitizeExpression(String(expr));
11 return eval(cleaned); // RCE
12}The regex uses word boundaries (\b), which can be bypassed with string concatenation.
Exploitation
1// Blocked: fs is a word
2process.getBuiltinModule('fs')
3
4// Bypasses word boundary: 'f'+'s' is not the word "fs"
5process.getBuiltinModule('f'+'s').readFileSync('/flag.txt','utf8')1curl -X POST http://target/cart/add \
2 -d "itemId=1" \
3 -d "quantity=process.getBuiltinModule('f'%2B's').readFileSync('/flag.txt','utf8')"The Fix
The package.json already includes mathjs - a hint about the intended solution. Replace eval() with a hardened mathjs instance:
1import { create, all } from 'mathjs';
2
3const math = create(all);
4
5// Disable dangerous functions
6math.import({
7 'import': function () { throw new Error('Function import is disabled') },
8 'createUnit': function () { throw new Error('Function createUnit is disabled') },
9 'reviver': function () { throw new Error('Function reviver is disabled') }
10}, { override: true });
11
12function safeEval(expr) {
13 try {
14 const result = math.evaluate(String(expr).trim().slice(0, 4096));
15 if (typeof result === 'number' && Number.isFinite(result)) {
16 return result;
17 }
18 return NaN;
19 } catch {
20 return NaN;
21 }
22}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
Flag:
HTB{N07_4LL_FL0W3R5_4R3_834U71FUL}