Self-defense for hackers. Detecting attacks in Active Directory

Date: 18/07/2025

This article explains how to find out that a hacker is operating in your domain, how to automate the detection process, and how to repel attacks in Active Directory.

If an attacker managed to avoid detection at the network level and reached Active Directory with a domain user account, then you’ve lost the first round. Now the intruder can deliver dozens of deadly attacks, and most of them are quite ‘silent’.

At this point, the second (and final!) round of your confrontation begins. The internal infrastructure and, potentially, the entire company’s business are at stake. However, if the domain withstands the simplest exploits that the hacker tries in the first minutes, and the intruder has no choice but to implement ‘noisier’ attacks, then the final battle can still be won.

You will learn to hear barely perceptible noises made by the attacker who has gained a foothold in Active Directory, identified your misconfigs, and started advancing to domain controllers.

Authentication

An attacker whose goal is to capture the heart of your internal infrastructure – Active Directory -inevitably has to deal with domain accounts. In the course of advancement, the hacker can make unsuccessful authentication attempts, enter incorrect or old passwords, or try to brute-force a password.

On the domain controller, you can monitor events 4776 (8004), 4768, or 4769 in a centralized way, but this requires domain admin privileges. By contrast, authentication attempts made by all domain users can be monitored with ordinary user rights (as you remember, all the proposed defense techniques don’t require exclusive rights and can be performed by any employee).

Using LDAP, you can track changes in the lastLogon/lastLogonTimestamp attribute and see the dynamics of successful authentications; changes in the badPasswordTime and badPwdCount attributes reflect the dynamics of unsuccessful authentications; while lockoutTime shows the dynamics of blockings. All you have to do is request in a loop objects whose above-listed attributes have changed within a certain period of time.

Searching for all users in Active Directory who had successful or unsuccessful authentication attempts over the specified period of time
Searching for all users in Active Directory who had successful or unsuccessful authentication attempts over the specified period of time

The automation below performs all the required operations: the script writes in real time which user has successfully authenticated, which user failed and how many times, and who was locked:

defence/ad/auth.py
from ldap3 import Server, Connection, SUBTREE, ALL
from time import sleep
from datetime import datetime
from getpass import getpass
from os import system
from sys import argv
from colorama import Fore
dc = argv[1]
userdom = argv[2] # "user@company.org"
USERS = { }
MAX_LOCKS = 50
MAX_FAILS = 100
server = Server(dc, get_info=ALL)
Connection(server, auto_bind=True)
root = server.info.naming_contexts[0]
server_time = server.info.other.get('currentTime')[0]
print("{root} {server_time}".format(root=root, server_time=server_time))
conn = Connection(server, user=userdom, password=argv[3] if len(argv) > 3 else getpass("password: "))
conn.bind()
alerts = []
def alert(user, action):
if user in alerts:
return
print("[!] Auth event detected: %s %s" % (user,action))
#system("telegram '{message}' &".format(message="Auth event detected: "+user+" "+action))
#system("email admin@company.org '{message}' &".format(message="Auth event detected: "+user+" "+action))
#system("sms PHONENUMBER '{message}' &".format(message="Auth event detected: "+user+" "+action))
system("zenity --warning --title='Auth event detected' --text='%s %s' &" % (user,action))
#system("echo 'Auth event detected' | festival --tts --language english")
alerts.append(user)
failures_time = {}
success_time = {}
fails = set()
locks = set()
timestamp = (int(datetime.strptime(server_time, "%Y%m%d%H%M%S.0Z").timestamp() if server_time else datetime.utcnow().timestamp()) + 11644473600) * 10000000
while True:
conn.search(root, '(&(objectCategory=person)(objectClass=user)(|(badPasswordTime>={timestamp})(lastLogon>={timestamp})))'.format(timestamp=timestamp), SUBTREE, attributes=["sAMAccountName", "badPasswordTime", "lastLogon", "badPwdCount", "lockoutTime"])
lasts = [timestamp]
for result in conn.entries:
dn = result.entry_dn
if result['sAMAccountName']:
user = result['sAMAccountName'].value
if user.lower() in ('incident','sp_farm'):
continue
auth_failure_count = ""
if result['badPwdCount']:
auth_failure_count = int(result['badPwdCount'].value)
if result['badPasswordTime']:
if user in failures_time and failures_time[user] < result['badPasswordTime'].value.timestamp():
print('[{now}]{red} "{user}" auth failure ({auth_failure_count}){reset}'.format(now=datetime.now().strftime("%d.%m.%Y %H:%M:%S"), badPasswordTime=result["badPasswordTime"].value.strftime("%d.%m.%Y %H:%M:%S"), red=Fore.RED, user=user, auth_failure_count=auth_failure_count, reset=Fore.RESET))
if user.lower() in USERS['fail']:
alert(user, 'failure')
lasts.append((result['badPasswordTime'].value.timestamp() + 11644473600) * 10000000)
if result['lockoutTime'].value and result['lockoutTime'].value.timestamp() == result['badPasswordTime'].value.timestamp():
print('[{now}]{red} "{user}" locked{reset}'.format(now=datetime.now().strftime("%d.%m.%Y %H:%M:%S"), red=Fore.LIGHTRED_EX, user=user, reset=Fore.RESET))
if user.lower() in USERS['lock']:
alert(user, 'locked')
locks.add(user)
fails.add(user)
failures_time[user] = result['badPasswordTime'].value.timestamp()
if result['lastLogon']:
if user in success_time and success_time[user] < result['lastLogon'].value.timestamp():
print('[{now}]{green} "{user}" auth success{reset}'.format(now=datetime.now().strftime("%d.%m.%Y %H:%M:%S"), lastLogon=result["lastLogon"].value.strftime("%d.%m.%Y %H:%M:%S"), green=Fore.GREEN, user=user, reset=Fore.RESET))
lasts.append((result['lastLogon'].value.timestamp() + 11644473600) * 10000000)
if user.lower() in USERS['auth']:
alert(user, 'auth')
if user in locks: locks.remove(user)
if user in fails: fails.remove(user)
success_time[user] = result['lastLogon'].value.timestamp()
if len(locks) > MAX_LOCKS:
alert("mass locks users", str(len(locks)))
if len(fails) > MAX_FAILS:
alert("mass fails users", str(len(fails)))
timestamp = int(max(lasts) + 1)
sleep(1)

Under normal circumstances, it’s no surprise that users sometimes enter their passwords with typos.

Failed authentication attempt isn
Failed authentication attempt isn’t followed by a successful one

In the above screenshot, the first failed authentication is immediately followed by a successful one: the user simply made a typo when entering their password for the first time, but then entered it correctly. However, the second failed authentication looks suspicious because the correct password was never entered.

The badPwdCount attribute shows the number of failed attempts so that you can conclude that somebody is guessing the password for this account.

Somebody tries to guess the password for multiple usernames taken from a wordlist
Somebody tries to guess the password for multiple usernames taken from a wordlist

In the above screenshot, you can see that somebody is trying to brute-force standard account names using a wordlist. Apparently, this is an echo of an attack on the external network perimeter. Services that use domain accounts for authentication can often be located there.

But in the next example, the situation is different: the usernames are definitely not taken from a wordlist.

Somebody tries to guess the password for internal users: one user, many passwords
Somebody tries to guess the password for internal users: one user, many passwords

If the attacker could find out these usernames only after penetrating into the network and connecting to Active Directory, then you can conclude with confidence: you are dealing with an internal intruder. Direct brute-forcing of domain accounts is a very rare phenomenon and, most likely, it’s a consequence of careless attacks.

However, an attacker can only try weakest passwords against a wide range of usernames without being blocked. If your company has a large number of accounts, then the attacker has a chance of success.

Hacker delivers a password spraying attack: one password, many users
Hacker delivers a password spraying attack: one password, many users

Such an attack can be easily detected: from the outside, it looks like a spontaneous burst of failed authentication attempts made by multiple users.

Password spraying attack (one password, many users) detected
Password spraying attack (one password, many users) detected

Similar activity can be detected if an attacker finds a valid password and delivers a password spraying attack in the hope that the password would work somewhere else.

In large companies, the flow of authentication events is huge, and it’s quite difficult to monitor it manually. Therefore, you can set up automatic notifications for certain users and certain events.

For instance, you can create a fictitious user so that no one is aware of its existence. Any authentication attempt performed by such a user can be considered an anomaly. Usernames obviously taken from a wordlist (e.g. guest, security, audit, testuser, test1, etc.) that aren’t used by anyone should also be considered an anomaly if they make failed authentication attempts. Finally, under normal circumstances, such standard usernames as administrator and the above-listed ones should never be blocked; otherwise, this also indicates an attack. In auth.py, this logic is described as follows:

defence/ad/auth.py
...
USERS = { # notifications
'auth': ['honeypot_user'],
'fail': ['guest', 'security', 'audit', 'testuser', 'test1'],
'lock': ['administrator', 'guest', 'security', 'audit', 'testuser', 'test1']
}
...

Since the script collects the full dynamics of authentication events, it would be great to analyze it somehow. Using standard Python math capabilities, you can easily construct a chart showing trends in successful and failed authentications and blockings:

defence/ad/auth-anal.py
from datetime import datetime
import matplotlib.pyplot as plt
HOURS = 24
auth_success = {}
auth_fail = {}
auth_lock = {}
while True:
try:
line = input()
except:
break
try:
date,time,user,*result = line.strip().split()
except:
continue
date = date.split("[")[1]
time = time.split("]")[0]
hour = time.split(":")[0]
result = " ".join(result)
try: datetime.strptime(date, '%d.%m.%Y')
except: continue
if result.find("success") != -1:
try: auth_success[date+"-"+hour] += 1
except: auth_success[date+"-"+hour] = 1
elif result.find("fail") != -1:
try: auth_fail[date+"-"+hour] += 1
except: auth_fail[date+"-"+hour] = 1
elif result.find("lock") != -1:
try: auth_lock[date+"-"+hour] += 1
except: auth_lock[date+"-"+hour] = 1
plt.plot(sorted(auth_success, key=lambda k:datetime.strptime(k, '%d.%m.%Y-%H').timestamp()), list(map(lambda d: auth_success[d], sorted(auth_success, key=lambda k:datetime.strptime(k, '%d.%m.%Y-%H').timestamp()))), label="success")
plt.plot(sorted(auth_fail, key=lambda k:datetime.strptime(k, '%d.%m.%Y-%H').timestamp()), list(map(lambda d: auth_fail[d], sorted(auth_fail, key=lambda k:datetime.strptime(k, '%d.%m.%Y-%H').timestamp()))), label="fail")
plt.plot(sorted(auth_lock, key=lambda k:datetime.strptime(k, '%d.%m.%Y-%H').timestamp()), list(map(lambda d: auth_lock[d], sorted(auth_lock, key=lambda k:datetime.strptime(k, '%d.%m.%Y-%H').timestamp()))), label="lock")
ax = plt.gca(); ax.set_xticks(ax.get_xticks()[::HOURS])
plt.legend()
plt.show()

This analysis is as simple as ABC, but the overall picture immediately becomes clear: the company actively operates during business hours; while brute-forcing attacks occur at night time.

Analysis of authentication events accumulated over the week: five workdays shown in green are accompanied by five blue peaks; the night between Friday and Saturday (in red) is accompanied by orange peak
Analysis of authentication events accumulated over the week: five workdays shown in green are accompanied by five blue peaks; the night between Friday and Saturday (in red) is accompanied by orange peak

However, authentication events are only a small part of what’s going on in Active Directory. Some events and processes are often not monitored even by Security Operations Centers.

Changes in Active Directory objects

Many attacks on the Active Directory infrastructure leave specific traces: respective attributes of objects are created or changed. Almost all such modifications indirectly affect the whenChanged attribute that reflects changes in a given object.

List of all Active Directory objects that underwent changes within a specified period of time
List of all Active Directory objects that underwent changes within a specified period of time

Importantly, the whenChanged attribute is updated even if object’s rights have changed, which makes it possible to detect nearly untraceable ACL attacks. To track the entire dynamics in Active Directory, all you have to do is make a preliminary snapshot of all attributes for all objects, analyze their ACLs, and compare differences in changed objects:

defence/ad/changes.py
from ldap3.protocol.microsoft import security_descriptor_control
from ldap3 import Server, Connection, SUBTREE, BASE, ALL, ALL_ATTRIBUTES
import pickle
from time import sleep
from datetime import datetime
from getpass import getpass
from os import system
from sys import argv
from re import match
from colorama import Fore
from winacl.dtyp.security_descriptor import SECURITY_DESCRIPTOR
from winacl.dtyp.sid import SID
from winacl.dtyp.ace import ADS_ACCESS_MASK
dc = argv[1]
ATTACKS = { # notifications
"SPN attack": {"attr": "^serviceprincipalname$", "dn": ".*"},
"RBCD attack" : {"attr": "^msds-allowedtoactonbehalfofotheridentity$", "dn": ".*"},
"ShadowCredentials attack" : {"attr": "^msds-keycredentiallink$", "dn": ".*"},
"membership changed": {"attr": "^member$", "dn": ".*admin.*"},
"GPO attack": {"attr": "^gpcfilesyspath$", "dn": ".*"},
"user object abuse": {"attr": "^scriptpath$", "dn": ".*"},
"ACL attack": {"attr": ".*generic_all.*", "dn": ".*"},
"sAMAccountName spoofing": {"attr": "^samaccountname$", "dn": ".*"},
"dNSHostName spoofing": {"attr": "^dnshostname$", "dn": ".*"},
"ADCS attack templates ESC4": {"attr": "^(msPKI-Certificate-Name-Flag|msPKI-Enrollment-Flag|msPKI-RA-Signature)$", "dn": ".*CN=Certificate Templates,.*"}
}
server = Server(dc, get_info=ALL)
Connection(server, auto_bind=True)
server_time = server.info.other.get('currentTime')[0]
if len(argv) < 4:
print(server_time)
print("\n".join(server.info.naming_contexts))
exit()
else:
root = argv[3]
userdom = argv[2] # "user@company.org"
conn = Connection(server, user=userdom, password=getpass("password: "))
conn.bind()
alerts = []
def alert(dn, attr, value, message):
if (dn,attr) in alerts:
return
print("[!] Danger changes detected: %s: %s=%s (%s)" % (dn, attr, value, message))
#system("telegram '{message}'".format(message="Danger changes detected %s: %s=%s (%s)" % (dn, attr, value, message)))
system("zenity --warning --title='Danger changes detected' --text='%s: %s=%s (%s)' &" % (dn, attr, value, message))
#system("echo 'Danger changes detected' | festival --tts --language english")
alerts.append((dn,attr))
cache_sid = {}
def resolve_sid(sid):
global cache_sid
if not sid in cache_sid:
cache_sid[sid] = None
for dn in objects:
if objects[dn].get("objectSid") == [sid]:
name = objects[dn]["sAMAccountName"]
cache_sid[sid] = name
break
return cache_sid.get(sid)
def parse_acl(nTSecurityDescriptor):
acl = SECURITY_DESCRIPTOR.from_bytes(nTSecurityDescriptor)
acl_canonical = {"owner": [acl.Owner.to_sddl() if acl.Owner else ""], "dacl":[]}
for ace in acl.Dacl.aces if acl.Dacl else []:
ace_canonical = {}
ace_canonical["who"] = SID.wellknown_sid_lookup(ace.Sid.to_sddl()) or resolve_sid(ace.Sid.to_sddl()) or ace.Sid.to_sddl()
ace_canonical["type"] = str(ace).split("\n")[0].strip()
for line in str(ace).split("\n")[1:]:
if line.strip():
field = line.split(":")[0].lower()
value = line.split(":")[1].strip()
ace_canonical[field] = value
acl_canonical["dacl"].append(ace_canonical)
return acl_canonical
def snapshot_create():
global objects
#results = conn.extend.standard.paged_search(search_base=root, search_filter='(objectClass=*)', search_scope=SUBTREE, attributes=ALL_ATTRIBUTES, paged_size=1000) # only attributes
results = conn.extend.standard.paged_search(search_base=root, search_filter='(objectClass=*)', search_scope=SUBTREE, attributes=ALL_ATTRIBUTES, controls=security_descriptor_control(sdflags=0x05), paged_size=1000) # with ACL
#conn.search(root, '(objectClass=*)', SUBTREE, attributes=ALL_ATTRIBUTES) # only attributes
#conn.search(root, '(objectClass=*)', SUBTREE, attributes=ALL_ATTRIBUTES, controls=security_descriptor_control(sdflags=0x05)) # with ACL
#conn.search(root, '(|(objectClass=pKICertificateTemplate)(objectClass=certificationAuthority))', SUBTREE, attributes=ALL_ATTRIBUTES, controls=security_descriptor_control(sdflags=0x05)) # with ACL
#for result in conn.entries:
for result in results:
if result.get('type') == 'searchResRef':
continue
#dn = result.entry_dn
#objects[dn] = result.entry_attributes_as_dict
dn = result["dn"]
objects[dn] = result["raw_attributes"]
for dn in objects: # because of resolve_sid()
if 'nTSecurityDescriptor' in objects[dn]:
objects[dn]['nTSecurityDescriptor'] = parse_acl(objects[dn]['nTSecurityDescriptor'][0])
open("objects.dat", "wb").write(pickle.dumps([objects,cache_sid]))
def snapshot_restore():
global objects, cache_sid
try:
objects, cache_sid = pickle.loads(open("objects.dat", "rb").read())
return True
except:
return False
def get_attrs(dn):
#conn.search(dn, '(objectClass=*)', BASE, attributes=ALL_ATTRIBUTES) # only attributes
#conn.search(dn, '(objectClass=*)', BASE, attributes=ALL_ATTRIBUTES, controls=security_descriptor_control(sdflags=0x05)) # with ACL
results = conn.extend.standard.paged_search(search_base=dn, search_filter='(objectClass=*)', search_scope=BASE, attributes=ALL_ATTRIBUTES, controls=security_descriptor_control(sdflags=0x05), paged_size=1000) # with ACL
result = next(results)
#attrs = conn.entries[0].entry_attributes_as_dict
attrs = result["raw_attributes"]
if attrs.get('nTSecurityDescriptor'):
attrs['nTSecurityDescriptor'] = parse_acl(attrs['nTSecurityDescriptor'][0])
return attrs
def print_diff(dn):
if not dn in objects:
return
def diff(attrs_before, attrs_after):
for attr in attrs_before:
if not attr in attrs_after:
print(f"{Fore.RED}delete %s: %s{Fore.RESET}" % (attr, str(attrs_before[attr])))
else:
if type(attrs_before[attr]) == dict:
diff(attrs_before[attr], attrs_after[attr])
else:
for value in attrs_before[attr]:
if not value in attrs_after[attr]:
print(f"{Fore.RED}delete %s: %s{Fore.RESET}" % (attr, value))
for attr in attrs_after:
if not attr in attrs_before:
print(f"{Fore.GREEN}new %s: %s{Fore.RESET}" % (attr, str(attrs_after[attr])))
for attack in ATTACKS:
if (match(ATTACKS[attack]["attr"].lower(), attr.lower()) or match(ATTACKS[attack]["attr"].lower(), str(attrs_after[attr]).lower())) and match(ATTACKS[attack]["dn"].lower(), dn.lower()):
alert(dn, attr, attrs_after[attr].decode(), attack)
else:
if type(attrs_after[attr]) == dict:
diff(attrs_before[attr], attrs_after[attr])
else:
for value in attrs_after[attr]:
if not value in attrs_before[attr]:
print(f"{Fore.GREEN}added %s: %s{Fore.RESET}" % (attr, value))
for attack in ATTACKS:
if (match(ATTACKS[attack]["attr"].lower(), attr.lower()) or match(ATTACKS[attack]["attr"].lower(), str(value).lower())) and match(ATTACKS[attack]["dn"].lower(), dn.lower()):
alert(dn, attr, value.decode(), attack)
attrs = get_attrs(dn)
diff(objects[dn], attrs)
objects[dn] = attrs
objects = {}
snapshot_restore() or snapshot_create()
print("[*] %d objects" % len(objects))
now = datetime.strptime(server_time, '%Y%m%d%H%M%S.0Z').timestamp() or datetime.utcnow().timestamp()
first_time = True
while True:
conn.search(root, f'(whenChanged>={datetime.utcfromtimestamp(now).strftime("%Y%m%d%H%M%S.0Z")})', SUBTREE, attributes=["distinguishedName", "whenChanged", "whenCreated"])
lasts = [now]
for result in conn.entries:
dn = result.entry_dn
changed = result['whenChanged'].value
created = result['whenCreated'].value
time = changed.strftime("%d.%m.%Y %H:%M:%S")
if changed == created:
if not first_time:
print(f'[{time}] "{dn}" created')
objects[dn] = get_attrs(dn)
lasts.append(created.timestamp())
else:
if not first_time:
print(f'[{time}] "{dn}" changed')
print_diff(dn)
lasts.append(changed.timestamp())
now = max(lasts) + 1
sleep(1)
first_time = False

This script consists of slightly more than a hundred lines of code. However, you can run it under any (even unprivileged) domain account, and it will monitor in real time what objects were created, deleted, or modified and show what attributes have changed in them, including ACL analysis to present the results in a canonical readable form.

A single script enabling you to see almost all attacks becomes a universal Active Directory monitoring tool.

Attacks targeting users

As attacks progress, the hacker identifies various access rights misconfigs in the Active Directory infrastructure, as well as relay attacks enabling the intruder to perform actions on behalf of another account. Ultimately, such attacks result in numerous harmful actions.

If an attacker has extended rights to a user account, they can change its password – and you’ll see this immediately thanks to the pwdLastSet attribute.

Changed user password detected
Changed user password detected

Needless to say, when you monitor password change events, your top priorities are service, admin, and other important accounts.

If the attacker has write access to a user object in Active Directory, they can compromise this account more accurately by adding an arbitrary LogonScript.

Hacker compromises a user by creating an attribute in the user object with a startup script executed at each logon
Hacker compromises a user by creating an attribute in the user object with a startup script executed at each logon

And you can track such activity.

LogonScript attack on a user object detected
LogonScript attack on a user object detected

Another way to attack a user object is as follows: the attacker adds the servicePrincipalName attribute to the object and delivers a targeted kerberoasting attack to capture that user’s password hash.

Hacker delivers a targeted kerberoasting attack against a user object
Hacker delivers a targeted kerberoasting attack against a user object

In the course of this attack, the script used by the attacker quickly creates an SPN attribute, requests a TGS Kerberos ticket (containing user password hash), and deletes SPN. Accordingly, if you check objects for changes frequently enough, you’ll see the characteristic attributes.

Targeted kerberoasting attack against a user object detected
Targeted kerberoasting attack against a user object detected

The targetedKerberoast.py script creates and deletes SPN very quickly so that it cannot be detected. However, an attacker can change SPN manually, and the changes.py script will detect such a change.

Attacks on computers

If an attacker has write access to the computer account object, they can use RBCD or Shadow Credentials technique to gain access to the victim’s PC.

Hacker seizes a computer using RBCD
Hacker seizes a computer using RBCD

This can be detected by the appearance of a specific attribute: msDS-AllowedToActOnBehalfOfOtherIdentity/msDs-KeyCredentialLink.

RBCD attack on computer account detected
RBCD attack on computer account detected

Under normal conditions, such attributes as Resource Based Constrained Delegation and Shadow Credentials are rarely used in regular domains. Therefore, the appearance of these attributes deserves your close attention.

Attacks on GPO

Finally, attention should be paid to Group Policy Objects. If an attacker compromises such an object, the impact can be catastrophic, especially if this policy applies to many hosts or users.

Hacker executes malicious code using a compromised Group Policy
Hacker executes malicious code using a compromised Group Policy

The most valuable attribute in a group policy is gPCFileSysPath: it points to the folder where an executable script or registry branch can be stored. In any case, a redirection attempt can be detected based on changes in the respective attribute.

An attack exploiting group policy detected
An attack exploiting group policy detected

Important: an attacker can also compromise a group policy object on the SYSVOL network drive, and you won’t see this.

Attacks on groups

If an attacker manages to gain access to a specific group, they can add an account to that group.

Hacker adds themselves to a compromised domain group
Hacker adds themselves to a compromised domain group

To add an object to a group, the member attribute is used, an you’ll immediately see its appearance.

Attack on a domain group detected
Attack on a domain group detected

Changes in membership of domain groups frequently occur in actively used domains. Accordingly, special attention should be paid to critical groups (e.g. Domain Admins, Enterprise Admins, Account Operators, Server Operators, Backup Operators, Print Operators, DnsAdmins, Organization Management, etc.). In most cases, the final stage of internal network attacks involves domain compromise.

Compromise of the domain admin group detected
Compromise of the domain admin group detected

A domain admin group compromise is a pretty ‘noisy’ event. A cautious hacker would rather abstain from it. But that doesn’t mean that you shouldn’t monitor such activity.

ACL attacks

ACL attacks are even more difficult to detect. Almost all above-described examples can be consequences of misconfigured access rights (ACL). Such misconfigs can be easily detected with BloodHound, and are often used as invisible paths leading a simple domain user to high-value targets and the domain admin. But are ACL modifications really undetectable? In fact, even slightest changes in rights result in implicit modifications of the whenChanged attribute, which means that the changes.py script can be used to detect them.

Changes in ownership

If an internal attacker has the required rights (GENERIC_ALL, WRITE_OWNER), they can change the owner of an object.

Hacker delivers an ACL attack to change the object owner
Hacker delivers an ACL attack to change the object owner

As soon as this happens, object’s nTSecurityDescriptor attribute (that stores all information about rights to that object) is changed. Information contained in it is presented in binary form, but the changes.py script can parse its structure to present the data in a canonical readable form. And you can immediately see suspicious changes.

ACL change of ownership attack detected
ACL change of ownership attack detected

Changes in ownership aren’t frequent events, and you should pay attention to every such case. But the progress of a real attack rarely stops at this point, and, most likely, something else should happen.

Changes in permissions

If an internal attacker discovers that their permissions make it possible to change permissions (WRITE_DACL) in some object, the hacker can assign the arbitrary code execution (ACE) right to that object. Most often, this involves the delegation of full permissions (GENERIC_ALL) to the object.

Hacker delivers an ACL GENERIC_ALL assignment attack
Hacker delivers an ACL GENERIC_ALL assignment attack

In the screenshot below, the internal attacker adds an ACE with the GENERIC_ALL (full access) mask.

ACL GENERIC_ALL assignment attack detected
ACL GENERIC_ALL assignment attack detected

In real infrastructures, the path from a user to the domain admin can be very thorny, and I recommend paying attention to changes in ACL of any object if GENERIC_ALL and WRITE_DACL are involved since these access rights have the strongest impact (each in its own situation).

Attacks on ADCS

ADCS security is a separate topic: as many as 15 privilege escalation vectors involve Active Directory Certificate Services. Monitoring of ADCS events is a headache. As a result, many companies simply disable ADCS to be on the safe side.

But if the ADCS configuration is stored in LDAP, then you can monitor changes in it using the same changes.py script. ESC4 is an example of ADCS abuse: an object associated with a certificate template in Active Directory is modified. If a hacker encounters a template with incorrect access rights, they can modify it to make it vulnerable to ESC1.

Hacker delivers ESC4 attack on ADCS
Hacker delivers ESC4 attack on ADCS

At the Active Directory level, this is manifested in the addition of new attributes to the attacked object.

Manual exploitation of the ADCS ESC4 vulnerability
Manual exploitation of the ADCS ESC4 vulnerability

And since something has changed in the object, you can detect such an attack.

ADCS ESC4 attack detected
ADCS ESC4 attack detected

Generally speaking, this namespace (Configuration) isn’t ‘inhabited’ by users, and nothing changes there by itself. Accordingly, any change in it is a good reason to raise a red flag.

Conclusions

Now you’re aware of sensitive aspects that can be monitored with regular user rights. All you have to do is make special LDAP requests and track changes in certain attributes of Active Directory objects. Just two simple scripts enable you to detect traces of many popular attacks.

An attack delivered by an internal intruder who has reached Active Directory can develop very quickly and require swift reaction. A modern SOC has a fundamental problem: the wide range of events it monitors can significantly slow down analysis and decision-making. But if you know in advance what to pay attention to (i.e. aspects that almost never give false positives), then you can react much faster.

On average, it takes ten steps for a pentester to completely compromise the internal infrastructure. This means that (in theory!) you can detect the attacker ten times and have time to react.

But what if the hacker managed to make it out to their target? In such a situation, you can still do something to make bastard’s life miserable. If an attacker penetrates into your computer, most probably, they are only interested in your password. And you, as a user, can have any passwords, even those containing wildcards and dangerous commands.

Password that can execute an OS command
Password that can execute an OS command

If an attacker carelessly inserts a password phrase into the command line, the command ‘hidden’ there would be executed. For example, if you want to stop a hacker, you can try to implement a command that deletes all files on the disk.

In other words, you can implement RCE on the hacker’s host.

Hacker kills their OS
Hacker kills their OS

A survey conducted among hackers, pentesters, and IT professionals showed that 21% or respondents had occasionally entered incorrect passwords in the command line; as a result, their Kali systems were killed during password reuse attacks. A simple warning was able to stop one hacker out of five. There is no guarantee whether such a trick would work in your case or not, but it definitely won’t make your password weaker. So, even if you have lost both rounds, you still have a chance to deliver a fatality to the hacker.

Good luck!

Related posts:
2022.01.12 — Post-quantum VPN. Understanding quantum computers and installing OpenVPN to protect them against future threats

Quantum computers have been widely discussed since the 1980s. Even though very few people have dealt with them by now, such devices steadily…

Full article →
2022.06.01 — Quarrel on the heap. Heap exploitation on a vulnerable SOAP server in Linux

This paper discusses a challenging CTF-like task. Your goal is to get remote code execution on a SOAP server. All exploitation primitives are involved with…

Full article →
2022.04.04 — Elephants and their vulnerabilities. Most epic CVEs in PostgreSQL

Once a quarter, PostgreSQL publishes minor releases containing vulnerabilities. Sometimes, such bugs make it possible to make an unprivileged user a local king superuser. To fix them,…

Full article →
2022.02.15 — EVE-NG: Building a cyberpolygon for hacking experiments

Virtualization tools are required in many situations: testing of security utilities, personnel training in attack scenarios or network infrastructure protection, etc. Some admins reinvent the wheel by…

Full article →
2023.06.08 — Croc-in-the-middle. Using crocodile clips do dump traffic from twisted pair cable

Some people say that eavesdropping is bad. But for many security specialists, traffic sniffing is a profession, not a hobby. For some reason, it's believed…

Full article →
2022.01.13 — Bug in Laravel. Disassembling an exploit that allows RCE in a popular PHP framework

Bad news: the Ignition library shipped with the Laravel PHP web framework contains a vulnerability. The bug enables unauthorized users to execute arbitrary code. This article examines…

Full article →
2022.02.15 — Reverse shell of 237 bytes. How to reduce the executable file using Linux hacks

Once I was asked: is it possible to write a reverse shell some 200 bytes in size? This shell should perform the following functions: change its name…

Full article →
2022.06.01 — Log4HELL! Everything you must know about Log4Shell

Up until recently, just a few people (aside from specialists) were aware of the Log4j logging utility. However, a vulnerability found in this library attracted to it…

Full article →
2023.02.21 — Pivoting District: GRE Pivoting over network equipment

Too bad, security admins often don't pay due attention to network equipment, which enables malefactors to hack such devices and gain control over them. What…

Full article →
2022.12.15 — What Challenges To Overcome with the Help of Automated e2e Testing?

This is an external third-party advertising publication. Every good developer will tell you that software development is a complex task. It's a tricky process requiring…

Full article →