Skip to main content

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 as DC01, my crown jewel will be called as Server01 and my own machine will be called as Client01
  • First I created the Server Administrators group on Server01 by specifying it as a Domain 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 the Server01 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 the Domain Controller and adding Helpdesk Admins to the Client01

  • But while adding Exchange Admins, I had to specify it as a Global Group instead of a Domain 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 the Domain 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 the Client01 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 the Exchange Admins group
  • As soon as the code detects that the number of admins have changed, it creates a file in the logs/alerts/ directory adminDetails

  • 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 the Domain 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 directory domainAdmins

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 Group Server Maintenance

  • We see a Slack alert is pushed as well as a file is created with the name of compDetails in the logs/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 both Helpdesk Admins and Server 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 the Helpdesk Admins group) is added to the Server Administrators group

  • On adding it, we will get a Slack alert as well as a file will be generated in the logs/alerts directory called helpdeskAdmins giving the sAMAccountName of that particular user

Detecting Password Spray Attempt

  • For this I fetched the user attributes badPwdCount and badPasswordTime
  • This is done using the function MonitorPasswordSprays() which in turn calls the function GetBadLogins() 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 to 0 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 to Unrestricted and import the module DomainPasswordSpray.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

References

  1. MSDN
  2. LDAP Search Filters
  3. LDAP Wiki
  4. Sending Automated Slack Messages