Skip to content

feat: add acl_abuse LDAP module for ACE chain enumeration#1176

Open
sup3rDav3 wants to merge 2 commits intoPennyw0rth:mainfrom
sup3rDav3:feature/acl-abuse-module
Open

feat: add acl_abuse LDAP module for ACE chain enumeration#1176
sup3rDav3 wants to merge 2 commits intoPennyw0rth:mainfrom
sup3rDav3:feature/acl-abuse-module

Conversation

@sup3rDav3
Copy link

@sup3rDav3 sup3rDav3 commented Mar 27, 2026

Description

Adds a new LDAP module acl_abuse that enumerates abusable ACEs from a target
principal to other AD objects, surfacing attack paths with inline attack suggestions.

Detections include:

  • WriteDACL, GenericAll, WriteOwner on users, groups, computers, and domain root
  • ForceChangePassword via extended rights GUID
  • WriteProperty on member (group abuse), servicePrincipalName (Kerberoast path),
    msDS-KeyCredentialLink (shadow credentials path)
  • DS-Replication-Get-Changes + Get-Changes-All (DCSync path)

This module was developed with the assistance of Claude (Anthropic) via claude.ai.
Claude assisted with debugging LDAP/impacket attribute access and ACE mask value corrections. All code was reviewed, tested, and verified by the author against a live Windows Server 2022 domain controller.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Deprecation of feature or functionality
  • This change requires a documentation update
  • This requires a third party update (such as Impacket, Dploot, lsassy, etc)
  • This PR was created with the assistance of AI (Claude via claude.ai — see description above)

Setup guide for the review

Environment:

  • Kali Linux (attacker machine)
  • Python 3.13
  • NetExec installed via poetry in dev mode

Target:

  • Windows Server 2022 domain controller
  • Domain: ad.local

Test setup — run on DC as Domain Admin:

# Create test accounts New-ADUser -Name "attacker" -SamAccountName "attacker" -AccountPassword (ConvertTo-SecureString "YourPassword123!!" -AsPlainText -Force) -Enabled $true New-ADUser -Name "victim" -SamAccountName "victim" -AccountPassword (ConvertTo-SecureString "YourPassword123!!" -AsPlainText -Force) -Enabled $true New-ADUser -Name "svc_test" -SamAccountName "svc_test" -AccountPassword (ConvertTo-SecureString "YourPassword123!!" -AsPlainText -Force) -Enabled $true New-ADGroup -Name "TestGroup" -SamAccountName "TestGroup" -GroupScope Global -GroupCategory Security # Grant attacker WriteDACL on victim $victim = Get-ADUser "victim" $attacker = Get-ADUser "attacker" $acl = Get-Acl "AD:$($victim.DistinguishedName)" $acl.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $attacker.SID, "WriteDacl", "Allow"))) Set-Acl "AD:$($victim.DistinguishedName)" $acl # Grant attacker GenericAll on Domain Admins $da = Get-ADGroup "Domain Admins" $acl2 = Get-Acl "AD:$($da.DistinguishedName)" $acl2.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $attacker.SID, [System.DirectoryServices.ActiveDirectoryRights]::GenericAll, "Allow"))) Set-Acl "AD:$($da.DistinguishedName)" $acl2 # Grant ForceChangePassword on svc_test $svc = Get-ADUser "svc_test" $acl3 = Get-Acl "AD:$($svc.DistinguishedName)" $acl3.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $attacker.SID, [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight, "Allow", [GUID]"00299570-246d-11d0-a768-00aa006e0529"))) Set-Acl "AD:$($svc.DistinguishedName)" $acl3

Run the module:

nxc ldap <DC_IP> -u attacker -p <password> -d <domain> -M acl_abuse nxc ldap <DC_IP> -u attacker -p <password> -d <domain> -M acl_abuse -o SHOW_ALL=true nxc ldap <DC_IP> -u attacker -p <password> -d <domain> -M acl_abuse -o TARGET_USER=otheruser nxc ldap <DC_IP> -u attacker -p <password> -d <domain> -M acl_abuse -o OUTPUT_FILE=/tmp/findings.json

Expected output:

ACL_ABUSE [CRITICAL] WriteDACL on victim -> Modify DACL to grant yourself GenericAll, then escalate ACL_ABUSE [CRITICAL] GenericAll on Domain Admins -> Full object control... ACL_ABUSE [+] ForceChangePassword on svc_test -> Reset target password without knowing current... 

Screenshot:

image

Checklist:

  • I have ran Ruff against my changes
  • I have added or updated the tests/e2e_commands.txt file if necessary
  • If reliant on changes of third party dependencies, such as Impacket, dploot, lsassy, etc, I have linked the relevant PRs in those projects
  • I have linked relevant sources that describes the added technique (blog posts, documentation, etc)
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (PR here: https://github.com/Pennyw0rth/NetExec-Wiki)
- Detects WriteDACL, GenericAll, WriteOwner on AD objects - Detects ForceChangePassword via extended rights GUID - Detects WriteProperty on member, servicePrincipalName, msDS-KeyCredentialLink - Detects DS-Replication-Get-Changes and Get-Changes-All (DCSync path) - Includes domain root object scanning for replication rights - Attack suggestions printed inline for each finding - Optional JSON export via OUTPUT_FILE - Tested against Windows Server 2022 domain controller
@azoxlpf
Copy link
Contributor

azoxlpf commented Mar 27, 2026

Nice ! Could you replace ldap3 with ldaptypes from impacket ? You can take a look at #1163for reference

@NeffIsBack
Copy link
Member

Thanks for the PR! Will test it when I have reviewed the rest of the PRs that have piled up, but I'll do a quick code review for now.

@NeffIsBack NeffIsBack added the enhancement New feature or request label Mar 27, 2026
Copy link
Member

@NeffIsBack NeffIsBack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick code review, without diving too deep into the logic

@@ -0,0 +1,393 @@
from ldap3.protocol.microsoft import security_descriptor_control
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @azoxlpf said, please replace it with the impacket equivalent

Comment on lines +72 to +74
context.log.fail(
"Could not resolve target principal — check TARGET_USER or auth credentials"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do such logs in one line. That just wastes space.

Comment on lines +77 to +79
context.log.display(
f"Resolved {len(principal_sids)} principal SID(s), enumerating ACEs..."
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Comment on lines +106 to +108
self.context.log.fail(
"Could not determine current username — use TARGET_USER option"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Comment on lines +113 to +116
user_entries = self._ldap_search(
f"(&(objectClass=user)(sAMAccountName={username}))",
["objectSid", "memberOf", "distinguishedName", "sAMAccountName"],
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use conn.search()

Comment on lines +287 to +289
"object_classes": obj_classes
if isinstance(obj_classes, list)
else [obj_classes],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic please in one line, otherwise this is confusing

Comment on lines +314 to +316
self.context.log.success(
f"Found {total} abusable ACE(s) — {critical} CRITICAL, {high} HIGH"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One line please

Comment on lines +328 to +360
def _ldap_search(self, filter_str, attributes, controls=None):
try:
response = self.conn.search(
searchFilter=filter_str,
attributes=attributes,
searchControls=controls,
)
if not response:
return []
return [e for e in response if isinstance(e, SearchResultEntry)]
except Exception as e:
self.context.log.debug(f"LDAP search failed ({filter_str}): {e}")
return []

def _parse_attributes(self, entry):
attrs = {}
try:
for attr in entry["attributes"]:
name = str(attr["type"])
vals = attr["vals"]
parsed_vals = []
for v in vals:
try:
parsed_vals.append(bytes(v))
except Exception:
parsed_vals.append(str(v))
if len(parsed_vals) == 1:
attrs[name] = parsed_vals[0]
else:
attrs[name] = parsed_vals
except Exception as e:
self.context.log.debug(f"Failed to parse entry attributes: {e}")
return attrs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can both be removed after reworking the rest.

Comment on lines +377 to +379
return ldaptypes.LDAP_SID(
data=ace["Ace"]["Sid"].getData()
).formatCanonical()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One line please

netexec nfs TARGET_HOST -u "" -p "" --enum-shares
netexec nfs TARGET_HOST -u "" -p "" --get-file /NFStest/test/test.txt ../test.txt
netexec nfs TARGET_HOST -u "" -p "" --put-file ../test.txt /NFStest/test
nxc ldap {host} -u {user} -p {pass} -M acl_abuse
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's at the wrong position. Please sort it into its category

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, this has the wrong syntax

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

3 participants