Skip to content

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

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

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

Conversation

@sup3rDav3
Copy link
Copy Markdown

@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

References


## Checklist:
- [x] I have ran Ruff against my changes
- [x] 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
- [x] I have linked relevant sources that describes the added technique (blog posts, documentation, etc)
- [x] I have performed a self-review of my own code
- [x] 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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

Comment thread nxc/modules/acl_abuse.py Outdated
@@ -0,0 +1,393 @@
from ldap3.protocol.microsoft import security_descriptor_control
Copy link
Copy Markdown
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

Comment thread nxc/modules/acl_abuse.py Outdated
Comment on lines +72 to +74
context.log.fail(
"Could not resolve target principal — check TARGET_USER or auth credentials"
)
Copy link
Copy Markdown
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.

Copy link
Copy Markdown
Author

@sup3rDav3 sup3rDav3 Mar 28, 2026

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.

Fixed. All multi-line log calls consolidated to single lines.

Comment thread nxc/modules/acl_abuse.py Outdated
Comment on lines +77 to +79
context.log.display(
f"Resolved {len(principal_sids)} principal SID(s), enumerating ACEs..."
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

See above

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think I got all of the multi-line log calls.

Comment thread nxc/modules/acl_abuse.py Outdated
Comment on lines +106 to +108
self.context.log.fail(
"Could not determine current username — use TARGET_USER option"
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

See above

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Got it.

Comment thread nxc/modules/acl_abuse.py Outdated
Comment on lines +113 to +116
user_entries = self._ldap_search(
f"(&(objectClass=user)(sAMAccountName={username}))",
["objectSid", "memberOf", "distinguishedName", "sAMAccountName"],
)
Copy link
Copy Markdown
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()

Copy link
Copy Markdown
Author

@sup3rDav3 sup3rDav3 Mar 28, 2026

Choose a reason for hiding this comment

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

Fixed. Using conn.search() directly for user and group lookups in _resolve_principal_sids.

Comment thread nxc/modules/acl_abuse.py Outdated
Comment on lines +287 to +289
"object_classes": obj_classes
if isinstance(obj_classes, list)
else [obj_classes],
Copy link
Copy Markdown
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

Copy link
Copy Markdown
Author

@sup3rDav3 sup3rDav3 Mar 28, 2026

Choose a reason for hiding this comment

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

Fixed. Consolidated the object_classes ternary onto a single line.

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

Choose a reason for hiding this comment

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

One line please

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed, thank you.

Comment thread nxc/modules/acl_abuse.py Outdated
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
Copy Markdown
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. Removed both _ldap_search and _parse_attributes. Now using conn.search() directly and parse_result_attributes from nxc.parsers.ldap_results throughout.

Comment thread nxc/modules/acl_abuse.py Outdated
Comment on lines +377 to +379
return ldaptypes.LDAP_SID(
data=ace["Ace"]["Sid"].getData()
).formatCanonical()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One line please

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Consolidated to single line.

Comment thread tests/e2e_commands.txt Outdated
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
Copy Markdown
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
Copy Markdown
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. Removed the incorrectly placed entry and added it to the LDAP Modules section in alphabetical order with the correct syntax.

@sup3rDav3
Copy link
Copy Markdown
Author

sup3rDav3 commented Mar 28, 2026

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

Done. Replaced security_descriptor_control from ldap3 with SDFlagsControl from impacket.ldap.ldapasn1. All detections verified working against Windows Server 2022.

@sup3rDav3
Copy link
Copy Markdown
Author

All feedback has been addressed, thank you for the review.

  • Replaced ldap3 security_descriptor_control with impacket SDFlagsControl
  • Consolidated all multi-line log calls to single lines
  • Using conn.search() directly throughout
  • Removed _ldap_search and _parse_attributes helpers, replaced with parse_result_attributes
  • Fixed e2e test syntax and position
  • All changes verified working against Windows Server 2022

- Adds --shares-depth argument to SMB protocol (default: 0, root only)
- When depth > 0, recursively checks subdirectories for write access
- Only runs subdir check when root write check fails (no performance impact by default)
- Reports writable subdirectories inline e.g. READ,WRITE (ad.local\scripts)
- Respects --no-write-check flag
- Compatible with --shares write filter
- Fixes: write permissions were only checked on top-level share directories
- Tested against Windows Server 2022 - detected writable SYSVOL\scripts subdir

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants