Register-ObjectEvent: A more efficient way to trigger a PowerShell script on a Windows Event

9 minute read

One of the things that is really cool about the Windows Task Scheduler is that there are so many different ways you can trigger an action. One of them is on a Windows Event. If you don’t need to know any data in the event, then this way is fine. You might find that it doesn’t trigger on every event if you end up with a small pile dumped all at once, but it will mostly be effective. If you do need to know something about the event that triggered the script, say a user name or file name, you will need to query the event log to get the most recent event that matches your query. But what happens if you get a series of events that drop all at once? Well, thats when you need to consider using Register-ObjectEvent to utilize some of .NET’s capabilities.

Register-ObjectEvent

Now Register-ObjectEvent is not specific to the Windows Event logs, it connects in with .NET events and can allow you to set up a PowerShell script in response to large swath of events. However, in this post I will only be covering how it can be used in response to events in the Windows Event logs. Just know that you can use it for other types of .NET events as well.

Building a Query

To build a query for Register-ObjectEvent, we are going to use xPath notation. Now this sounds complicated, but only if you don’t know how to cheat ;) What you’ll do is to open up the Event Viewer, filter the log in the GUI, and then look at the results on the XML tab.

For example, if I wanted to query the Security logs for failed login attempts, I would look for events with the ID of 4625. So here’s what my filter would look like in the GUI:

Event Filter GUI

And then if we switch to the XML tab, here’s how to find the data that you need:

XML Data

Just copy everything in between the angle brackes of the select node.

Code

With that xPath query, we’ll create the query object using .NET:

$eventId = 4625
$logName = 'Security'
$select = "*[System[(EventID=$eventId)]]"
$query = [System.Diagnostics.Eventing.Reader.EventLogQuery]::new($logName, [System.Diagnostics.Eventing.Reader.PathType]::LogName, $select)

Building a Watcher

Building the watcher is easy, we’ll just create the watcher using .NET, making sure to enable it as well:

$watcher = [System.Diagnostics.Eventing.Reader.EventLogWatcher]::new($query)
$watcher.Enabled = $true

Built in event properties

I mentioned earlier that using this process to script on events is useful because you get the event that triggered it as an accessible object in an automatic variable call $eventargs. Specifically in the EventRecord property. So if we wanted to get the source IP address, targeted account and domain, we might write something like this to a log:

@"
- Attempted logon from: $($eventArgs.EventRecord.properties[19].value)
  - Targeting account: $($eventArgs.EventRecord.properties[5].value)
  - on domain: $($eventArgs.EventRecord.properties[6].value)
"@

Examing the built in properties

All the other properties that you would expect are available to you as well. If you want to run the script and examine this object, you can add this an additional line in your action to assign that value to a global variable:

$global:Event = $eventArgs

Then, after the action triggers at least once, you can have a look at the $Event object.

Action

When running the Register-ObjectEvent cmdlet, we will need to specify an action as a scriptblock. This is what will run when the event is triggered.

In my example, I wrote this simple script to demonstrate the folly of opening up RDP to the world:

$action = {
    $string = @"
- Attempted logon from: $($eventArgs.EventRecord.properties[19].value)
- Targeting account: $($eventArgs.EventRecord.properties[5].value)
- on domain: $($eventArgs.EventRecord.properties[6].value)
"@
    Write-Host $string
    $string | Out-File C:\tmp\security.log -Append
}

Bringing it all together

With all of the explanation out of the way (if something doesn’t make sense, let me know in the comments), here’s what that full script might look like:

$eventId = 4625
$logName = 'Security'
$select = "*[System[(EventID=$eventId)]]"
$query = [System.Diagnostics.Eventing.Reader.EventLogQuery]::new($logName, [System.Diagnostics.Eventing.Reader.PathType]::LogName, $select)
$watcher = [System.Diagnostics.Eventing.Reader.EventLogWatcher]::new($query)
$watcher.Enabled = $true
$action = {
    $string = @"
$(Get-Date -Format yyyyMMddTHHmmssffff) - Attempted logon from: $($eventArgs.EventRecord.properties[19].value)
  - Targeting account: $($eventArgs.EventRecord.properties[5].value)
  - on domain: $($eventArgs.EventRecord.properties[6].value)
"@
    Write-Host $string
    $string | Out-File C:\tmp\badlogins.log -Append
}
$job = Register-ObjectEvent -InputObject $watcher -EventName 'EventRecordWritten' -Action $action
Receive-Job $job

Running Receive-Job at the ends allows the output to be displayed in your PowerShell window (Notice the Write-Host). It is not necessary to receive the job for it to trigger. I was just amusing myself by watching failed login attempts scroll by.

Now if we open up a PowerShell window and run that command, we’ll get notified to the console and the log file every time there is a bad login attempt on the machine that it is running on. Here’s some entries from my log with slightly different formatting:

- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.35 targeting account: ADMINISTRATOR on domain:
- Attempted logon from: 193.93.62.73 targeting account: VSANCHEZ on domain:
- Attempted logon from: 193.93.62.73 targeting account: VCOLLINS on domain:
- Attempted logon from: 193.93.62.73 targeting account: VMORRIS on domain:
- Attempted logon from: 193.93.62.2 targeting account: ADMIN on domain:

And these are actual entries I used to demonstrate to a non-believer that automated scanners are real! Don’t leave RDP open to the world without taking precautions!

Another example

Another good use case that I came across while browsing Reddit is to run a script in response to group membership changes in Active Directory. Here’s what that might look like:

$eventId = 4756
$logName = 'Security'
$select = "*[System[(EventID=$eventId)]]"
$query = [System.Diagnostics.Eventing.Reader.EventLogQuery]::new($logName, [System.Diagnostics.Eventing.Reader.PathType]::LogName, $select)
$watcher = [System.Diagnostics.Eventing.Reader.EventLogWatcher]::new($query)
$watcher.Enabled = $true
$action = {
    $subject = "User Added to Security Group"
    $server = "mail.domain.com"
    $from = "noc@domain.com"
    $to = "noc@domain.com"

    #Extract User that's permission was changed
    $username = Get-ADUser -Identity $eventargs.EventRecord.properties[1].value | select-object -ExpandProperty SamAccountName

    #Extract the group they were added to
    $groupName = $eventargs.EventRecord.properties[2].value

    #Extract who made the change
    $actorUsername = Get-ADUser -Identity $eventargs.EventRecord.properties[5].value | select-object -ExpandProperty SamAccountName


    $body = @"
A User has been added to a security group in Active Directory.<br><br>
<b>Username:</b> $username<br>
<b>Security Group:</b> $groupName<br>
<b>Change Made By:</b> $actorUsername
"@

    #Sending an e-mail.
    Send-MailMessage -From $From -To $To -SmtpServer $Server -Body "$Body" -BodyAsHtml -Subject $Subject#>
}
$job = Register-ObjectEvent -InputObject $watcher -EventName 'EventRecordWritten' -Action $action
Receive-Job $job

However, if your inbox is anything like mine, getting more emails is not a good idea. I would suggest posting this to your chat platform of choice. You could post some data to an Azure Logic App that is connected to Teams or Slack and then you wouldn’t have to worry about getting the appropriate modules set up.

Running the script via the Task Scheduler

The last thing I’ll leave you with is getting the script to run on your Windows device of choice. Ironically enough, the best way is to run it with Task Scheduler. But what you’ll want to do is to add some additional arguments to make sure that the script runs to register the event and that it stays running. As soon an the PowerShell windows closes, the event will stop firing. So I recommend running the script at startup with some additional arguments passed to PowerShell (take note of the -NoExit flag):

-NoProfile -WindowStyle Hidden -Noninteractive -NoExit -file C:\scripts\EventScript.ps1

Conclusion

And what will be cool about this script is that you can register as many events as you want. Want to grab successful logins as well? Add in an action for event 4624. Maybe you want to notify when a user is disabled? Add in event 4725. The options are as abundant as there are event IDs.

Leave a Comment