Poor Man's Threat Hunting
Overview
This blog is about my solutions to the challenge of session - 2 of the 3 machine session of Auror Project by Sudarshan Pisupati
The challenge is about detecting changes to Active Directory Security Groups and Domain and Local Admins. The challenge overview is given as something as follows:
**
There will be 3 machines in this lab
- 1 Domain Controller
- 1 Server which is designated the “crown jewel” server
- 1 machine where you will test your solution designated as your own machine
Create and distribute security groups and members
- “Server Administrators” is local administrator on crown jewel server - has 5 members
- “Server Maintenance” has RDP rights - has 5 members. 2 members are also Server Administrators.
- “Helpdesk Admins” is local administrator on your own machine - has 5 members
- “Domain Admins” has 5 members
- “Exchange Admins” is a local administrator on the domain controller - has 5 members
**
The Use Cases are something as follows :
**
Attack surface detection use cases
- Gather the count of administrators on the crown jewel machine and domain controller (including local accounts). Detect when this number changes.
- Detect when a computer account is added to any of the created domain security groups.
- Detect a change to the domain admins group membership and alert in slack
- Detect when a helpdesk administrator is also a server administrator
- Detect an attempt to spray passwords using user attributes
**
The Test Cases are as follows:
**
Test Cases:
- Add a local administrator account to the crown jewel machines
- Create a group with 5 members and add it to the server administrators group
- Add a computer account to the domain admins group
- Add server administrator group membership to one of the members of the “helpdesk admins”
- Password spray against all users using an automated tool
**
The Lab Setup
I tried doing the setup, as manually as possible. Since I wanted to know the inner working of how these groups are created and how security groups are give local administrators access
- In this Lab my
Domain Controller
will be called asDC01
, my crown jewel will be called asServer01
and my own machine will be called asClient01
- First I created the
Server Administrators
group onServer01
by specifying it as aDomain Local
group (Refer to : Add Domain Users local Admin Group GPO) - I then used GPO to specify that group as a local group
- Once it was done, I used,
gpupdate /force
on theServer01
to update the Group Policy - Then added this group as a local admin group
net localgroup administrators "Server Administrators" /add
- Check that this group is added using
net localgroup administrators
-
The same thing I did to add
Exchange Admins
as a local admin to theDomain Controller
and addingHelpdesk Admins
to theClient01
-
But while adding
Exchange Admins
, I had to specify it as aGlobal Group
instead of aDomain Local
group -
Now to create members I wrote a python script first to generate usrenames
-
The userlist I took from Badblood
import random
f = open("names.txt","r")
firstnames = []
lastnames = []
for i in f.read().splitlines():
firstnames.append(i.strip())
f.close()
f = open("names.txt","r")
for i in f.read().splitlines():
lastnames.append(i.strip())
n = int(input("Enter the number of names : "))
for i in range(n):
name = str(random.choice(firstnames)) + " " + str(random.choice(lastnames))
print(name + ":" + str(name[0]).lower() + str(name.split(' ')[1]).lower())
f.close()
- Then I wrote a powershell script to create the users
$pass = ConvertTo-SecureString "Passw0rd" -AsPlainText -Force
$users = Get-Content -Path C:\Users\Administrator\Documents\userlist.txt
ForEach ($user in $users)
{
echo $user
$name = $user.Split(":")[0]
$loginname = $user.Split(":")[1].replace(' ','')
New-ADUser -Name $name -PasswordNeverExpires $true -SamAccountName $loginname -UserPrincipalName $loginname -AccountPassword $pass -Enabled $true
}
- Then I wrote another script, which will add users to a group
$users = Get-Content -Path C:\Users\Administrator\Documents\userlist.txt
ForEach ($user in $users) {
$name = $user.Split(":")[1].replace(' ','').replace('\n','')
Add-ADGroupMember -Identity "Helpdesk Admins" -Members $name
}
- Using these scripts and some manual work, I created the Lab setup
Writing Code for Use Cases
- Since the main challenge was to NOT to execute and code on the Crown Jewel or the Domain Controller, the best way I could find was to use LDAP Queries to fetch information about groups and users and computers
- For this, I created a tool called LANALyser, which uses raw LDAP queries to fetch information about the Groups, Users and Computers.
- This tool can be run from any user on any of the computers, including the
Client01
- This tool is heavily based on one of my previous project LDAPFury which takes direct LDAP queries from the commandline arguments and gets the information from there
Basic architecture of the solution
- The code that runs on the
Client01
fetches all the information required and send them to another Linux server running a python server - This python server, then analyses the results and determines alerts on the basis of given use cases
- This is done to reduced computer resources on the
Client01
machine and gives me the flexibility to add more modules in future
Gathering the Total count of Administrators and Detecting change in the number of administrators
- First of all to fetch Domain information, I created a function containing all the LDAP queries that I’ll be using along with their keys
public static string GetFilter(string FilterType)
{
IDictionary<string, string> FilterTypes = new Dictionary<string, string>();
FilterTypes.Add("Members of a group", "(memberOf=CN=Domain Admins,CN=Users,DC=dosxuz,DC=local)");
FilterTypes.Add("Server Administrators", "(memberOf:1.2.840.113556.1.4.1941:=CN=Server Administrators,DC=dosxuz,DC=local)");
FilterTypes.Add("Domain Admins", "(memberOf=CN=Domain Admins,CN=Users,DC=dosxuz,DC=local)");
FilterTypes.Add("Exchange Admins", "(memberOf:1.2.840.113556.1.4.1941:=CN=Exchange Admins,DC=dosxuz,DC=local)");
FilterTypes.Add("Helpdesk Admins", "(memberOf:1.2.840.113556.1.4.1941:=CN=Helpdesk Admins,DC=dosxuz,DC=local)");
FilterTypes.Add("Server Maintenance", "(memberOf:1.2.840.113556.1.4.1941:=CN=Server Maintenance,DC=dosxuz,DC=local)");
FilterTypes.Add("Get Computer Accounts", "(&(objectClass=computer))");
FilterTypes.Add("All Users", "(&(objectClass=person)(objectCategory=user))");
return FilterTypes[FilterType];
}
-
For the first use case, I used the queries to fetch users from
Domain Admin, Server Administrators, Exchange Admins, Helpdesk Admins
-
I used the query
(memberOf:1.2.840.113556.1.4.1941:=CN=Server Administrators,DC=dosxuz,DC=local)
to fetch users from the local administrator groups -
Then I used the query
(memberOf=CN=Domain Admins,CN=Users,DC=dosxuz,DC=local)
to fetch users from theDomain Admins
group. -
The function
MonitorAdmins
fetches users from all these groups and sends them to the python server, which in turn keeps a track of them
public static admindetails MonitorAdmins()
{
DirectoryEntry de = new DirectoryEntry("LDAP://RootDSE");
string rootldap = "LDAP://" + de.Properties["defaultNamingContext"][0].ToString();
DirectoryEntry d = new DirectoryEntry(rootldap);
DirectorySearcher ds = new DirectorySearcher(d);
int admincount = 0;
admindetails ad = new admindetails();
string[] sAMAccountNames = new string[10000];
ad = GetNumberOfUsers("Server Administrators", ds);
Array.Copy(ad.sAMAccountNames, 0, sAMAccountNames, 0, ad.admincount);
admincount = admincount + ad.admincount;
ad = GetNumberOfUsers("Domain Admins", ds);
Array.Copy(ad.sAMAccountNames, 0, sAMAccountNames, admincount, ad.admincount);
admincount = admincount + ad.admincount;
ad = GetNumberOfUsers("Exchange Admins", ds);
Array.Copy(ad.sAMAccountNames, 0, sAMAccountNames, admincount, ad.admincount);
admincount = admincount + ad.admincount;
ad = GetNumberOfUsers("Helpdesk Admins", ds);
Array.Copy(ad.sAMAccountNames, 0, sAMAccountNames, admincount, ad.admincount);
admincount = admincount + ad.admincount;
string[] uniqueNames = RemoveDuplicates(sAMAccountNames);
for (int i=0; i<admincount;i++)
{
if (uniqueNames[i] == null)
{
admincount = i;
break;
}
Console.WriteLine(uniqueNames[i]);
}
Console.WriteLine("Admin Count : " + admincount);
ad.admincount = admincount;
ad.sAMAccountNames = uniqueNames;
return ad;
}
-
This is included in the admincount.py of the server.
-
When the server is started initially and the setup is created fresh
-
The client code sends the initial information to the server, which in turn saves them in log files on the Linux server
-
Later on whenever any change is made on the Active Directory, it compares the new results with the ones present on the server and generates alerts if anything different is there
-
Now start the the
server.py
code first
- The run the
LANALyser.exe
code on theClient01
machine
-
We will see that they have started exchanging information
-
Now adding a new user to any of the Administrator groups will generate an alert
- Here we see that we have added the
rdas
user to theExchange Admins
group - As soon as the code detects that the number of admins have changed, it creates a file in the
logs/alerts/
directoryadminDetails
- Showing the total number of administrators
Detecting Change to the Domain Admins Group
- The same LDAP query
(memberOf=CN=Domain Admins,CN=Users,DC=dosxuz,DC=local)
can also detect any change in theDomain Admins
group - Let’s say there is a user added to the
Domain Admins
group, it will be reflected in both this use case and the above use case - Let’s say a Group is added to
Domain Admins
then the members of that group will be reflected in this same query
- This will generate a file in the
logs/alerts
directorydomainAdmins
Detect when a Computer Account is added to any Domain Security Groups
- This is done using the function
MonitorComputerAccounts()
public static compDetail MonitorComputerAccounts()
{
DirectoryEntry de = new DirectoryEntry("LDAP://RootDSE");
string rootldap = "LDAP://" + de.Properties["defaultNamingContext"][0].ToString();
DirectoryEntry d = new DirectoryEntry(rootldap);
DirectorySearcher ds = new DirectorySearcher(d);
compDetail cd = new compDetail();
compDetail[] compDetails = new compDetail[100];
compDetails = GetComputerAccounts("Get Computer Accounts",ds);
Console.WriteLine("Sanity Check");
foreach (compDetail i in compDetails)
{
if (i.ismember == true)
{
Console.WriteLine("Member of Group : " + i.ismember);
Console.WriteLine("Group Name : " + i.groupname);
Console.WriteLine("Computer Name : " + i.compName);
cd = i;
}
}
return cd;
}
- It uses the function
GetComputerAccounts()
which uses the LDAP filter(&(objectClass=computer))
to fetch all the computer accounts - Now if any of the computer accounts is a member of any Domain Security groups, then an alert will be generated
- Here the Computer Account
Server01
is added to the Security GroupServer Maintenance
- We see a Slack alert is pushed as well as a file is created with the name of
compDetails
in thelogs/alerts
directory
Detect when a Helpdesk Admin is also a member of Server Administrators Group
- For this I use the function
MonitorHelpdeskAdmins()
public static string MonitorHelpdeskAdmins()
{
DirectoryEntry de = new DirectoryEntry("LDAP://RootDSE");
string rootldap = "LDAP://" + de.Properties["defaultNamingContext"][0].ToString();
DirectoryEntry d = new DirectoryEntry(rootldap);
DirectorySearcher ds = new DirectorySearcher(d);
admindetails ad = GetNumberOfUsers("Helpdesk Admins", ds);
admindetails ad1 = GetNumberOfUsers("Server Administrators", ds);
string hue = null;
foreach(string i in ad.sAMAccountNames)
{
if (Array.Exists(ad1.sAMAccountNames, element => element == i))
{
Console.WriteLine("This Helpdesk Admin is also a member of Server Adminstrators : " + i);
hue = i;
}
}
if (hue == null)
{
return "Nothing";
}
else
{
return hue;
}
}
-
This function in turn calls the function
GetNumberOfUsrs()
to fetch the users from bothHelpdesk Admins
andServer Administrators
and then checks if any of the member is present in both the groups -
If a user is present in both the groups then is sends the
sAMAccount
name of that particular user -
Here we see that the user
mjensen
(a member of theHelpdesk Admins
group) is added to theServer Administrators
group
- On adding it, we will get a Slack alert as well as a file will be generated in the
logs/alerts
directory calledhelpdeskAdmins
giving thesAMAccountName
of that particular user
Detecting Password Spray Attempt
- For this I fetched the user attributes
badPwdCount
andbadPasswordTime
- This is done using the function
MonitorPasswordSprays()
which in turn calls the functionGetBadLogins()
to get a list of bad logins (if there is any)
public static badlogin[] MonitorPasswordSprays()
{
DirectoryEntry de = new DirectoryEntry("LDAP://RootDSE");
string rootldap = "LDAP://" + de.Properties["defaultNamingContext"][0].ToString();
DirectoryEntry d = new DirectoryEntry(rootldap);
DirectorySearcher ds = new DirectorySearcher(d);
badlogin[] bl = new badlogin[500];
bl = GetBadLogins("All Users", ds);
List<badlogin> b2 = new List<badlogin>();
foreach(badlogin i in bl)
{
if (i.badPwdCount != 0)
{
b2.Add(i);
}
}
return b2.ToArray();
}
public static badlogin[] GetBadLogins(string FilterType, DirectorySearcher ds)
{
ds.Filter = GetFilter(FilterType);
string[] properties = { "badPasswordTime", "badPwdCount", "sAMAccountName" };
List<badlogin> logins = new List<badlogin>();
try
{
SearchResultCollection results = ds.FindAll();
if (results.Count > 0)
{
foreach (SearchResult result in results)
{
badlogin b = new badlogin();
foreach (string i in properties)
{
if (result.Properties[i.ToLower()].Count > 0)
{
var prop = result.Properties[i.ToLower()][0];
Type tp = prop.GetType();
if (tp.Equals(typeof(string)) && i.ToLower().Contains("samaccountname"))
{
Console.WriteLine(i + " : " + prop.ToString());
b.sAMAccountName = prop.ToString();
}
else if (tp.Equals(typeof(Int64)) && i.ToLower().Contains("badpasswordtime"))
{
Console.WriteLine(i + " : " + prop.ToString());
b.badPasswordTime = Int64.Parse(prop.ToString());
}
else
{
Console.WriteLine(i + " : " + prop.ToString());
b.badPwdCount = Int32.Parse(prop.ToString());
}
}
else
Console.WriteLine(i + " : " + "Not Found");
}
Console.WriteLine();
Console.WriteLine("------------------------------------------");
Console.WriteLine();
logins.Add(b);
}
}
else
{
Console.WriteLine("No result found");
}
}
catch (ArgumentException e)
{
if (e.ToString().Contains("search filter is invalid"))
{
Console.WriteLine("Invalid LDAP Query");
}
}
return logins.ToArray();
}
- If the
badPwdCount
user attribute is not equal to0
then it is considered as a “bad login” - Then this “bad login” will be sent to the python server to be analysed
- This will contain the
badPasswordTime, badPwdCount, sAMAccount
of the account where this attempt was made
Analysing the bad login attempts
- The bad login attempt are analysed on the python server
- The script passwordSprayAnalysis.py contains the following code
import base64
import os
import socket
from notification import send_notifications
def ReceiveBadPassDetails(s):
conn, add = s.accept()
data = conn.recv(1024)
decodedString = base64.b64decode(data).decode()
print(decodedString)
if ("empty" in decodedString):
print("No password spray")
return
MonitorPassSprays(decodedString)
conn.send(b"Received Bad Logins\n")
conn.close()
def MonitorPassSprays(badlogins):
try:
badloginTimes = []
badloginCounts = []
sAMAccountNames = ""
for index, badlogin in enumerate(badlogins.split(":")):
try:
if index == len(badlogins.split(":")) - 1:
break
badloginTimes.append(badlogin.split(",")[1])
badloginCounts.append(badlogin.split(",")[2])
sAMAccountNames = sAMAccountNames + " , " + badlogin.split(",")[0]
except Exception as e1:
print("Exception in Bad Pass analysis")
print(e1)
pass
remainders = []
for badloginTime in badloginTimes:
remainders.append(int(int(badloginTime)/10**7))
print("Remainders : ")
c = 0
head = 0
for remainder in remainders:
head = remainder
if (head == remainder):
c += 1
if (c > 3):
if os.path.exists("logs/alerts/passwordSprayDetected"):
if os.path.getsize("logs/alerts/passwordSprayDetected") == 0:
f = open("logs/alerts/passwordSprayDetected","a")
c = "Number of attempted password sprays on accounts : " + str(c - 1)
f.write(c)
f.write(sAMAccountNames)
f.close()
send_notifications("There might be a password spray incident", (f"Password Spray Detected :key:"), "#de1010")
else:
f = open("logs/alerts/passwordSprayDetected","w")
c = "Number of attempted password sprays on accounts : " + str(c - 1)
f.write(c)
f.write(sAMAccountNames)
send_notifications("There might be a password spray incident", (f"Password Spray Detected :key:"), "#de1010")
f.close()
except Exception as e:
print("Exception occured : ")
print(e)
return
- The logic of this analysis is based on the time gaps between the bad login attempt
- The time stamp that we get is a Microsoft LDAP time stamp (which is of 18 digits)
- Now in this time stamp the last 7 digits represent time intervals which are less that 1 second
- Therefore, I extract the first 11 digits of the time stamp and check how many of these accounts have the same 11 digits
- This means that, I am checking how many accounts have bad login attempts in the time interval of 1 second.
- Now if this number is more that 3 then I send an alert that there is a password spray attempt or else not (you can change this number according to your preference, but it will fail against sophisticated password spray attempts. This is the reason why Consuming Event IDs is also important)
Testing a password spraying script
-
For this I used the script DomainPasswordSpray.ps1
-
Login as the Administrator or an account with administrative privileges
- Then set the
Execution
policy toUnrestricted
and import the moduleDomainPasswordSpray.ps1
(Remember to switch of Defender if you’re using any public script)
- Run the function
Invoke-DomainPasswordSpray
with any random password as follows
- A file will be created with the
sAMAccountNames
of the accounts against whom the password spray was performed and the number of such accounts
- It will also generate a slack alert
- This is how we can detect a typical password spray attempt to some extent of accuracy
Future Scope of improvements
I initially created this to solve only the above challenge, but in future I might add some more improvements and features to it
- Detect whenever a new Group policy is created and a local user is created through it
- Contain robust LDAP queries and modules, which can be pushed at runtime to detect any changes in the Active Directory environment
- Improve sync between the server and the client
- Solve the problem where admins are counted redundantly
Conclusion
Using LDAP queries to fetch information about the Active Directory Groups, Users and Computers can be pretty powerful in some cases. It provides us a way to monitor changes to the Active Directory environment, in almost real-time and writing custom code from scratch can also be pretty powerful.
However, in some cases, we might not get some results or sometimes get false positives. For example, while analyzing password spray attempts, we are depending on the fact that the password spray is made in a short duration of time, which may not be true in all cases. But if we consider all the bad password counts to be a password spray attempt, then also we might get false positives, as there might be legitimate scripts or sometimes user trying to login to the machine. This is why sometimes, Event ID analysis is more important to distinguish between automated brute force attempts and manual login attempts
At the end, this task has helped me understand some nuances of the Active Directory Security Groups. I am aware that this tool is not perfect, and can be improved to further improve the accuracy. I will keep working on it and updating in this blog.
Original Solution Code : Poor Man’s Threat Hunting