On Quickly Investigating a SQL Monitor Custom Security Alert
Phil Factor offers a clever way to report on a SQL Server intrusion, with a query that shows a full narrative description of all the security-related changes that have been detected by a set of SQL Monitor custom metrics.
I recently wrote a series of article that described four SQL Monitor custom metrics, designed purely to monitor for the signs of intrusion, or other unauthorized database changes. These metrics allow you to implement separate strategies for:
- Detecting SQL Injection attacks – the metric uses Extended Events to capture errors related to incorrect SQL syntax, attempts to access an invalid object, and so on
- Spotting unauthorized SQL Server configuration changes –using an Extended Event session to detect the informational error massages triggered by changes to security-sensitive configuration settings such as
xp_cmdshell
,Ad
Hoc
Distributed
Queries
and so on. - Checking for database drift – using Extended Events to detect metadata changes, meaning someone added, altered, or dropped an object (other than in
tempdb
). - Monitoring for changes in permissions, users, logins and roles – this custom metric mines details of these changes from the default trace.
These four metrics, combined, will provide evidence of an attack. It would also be possible to check for suspicious clauses in the SQL that has been executed, but that would take rather more CPU resources.
This article pulls together everything we’ve learned so far to provide a way of reporting on an intrusion. It will provide a ‘narrative’ description of all the changes that have been detected, allowing the DBA to investigate fully the sequence of unusual events. If you want to try it out, you’ll need to have created all the Extended Events sessions, and associated custom metrics, as described in each of the above four articles.
Why the need to be ready?
Imagine that SQL Monitor starts reporting some alarming custom security alerts.
How do you find out quickly what’s happened? Given the nature of the alerts, you may be facing an intrusion and a possible leakage of data, so you need to do things quickly, and under stress.
You have the alerts from SQL Monitor, but this will only tell you that suspicious things have happened. You now need to drill into the detail to find out what happened, and the order in which it happened. To do this, you need to look in different places, which will bring different data to the party. The default trace and Extended Events have rather different information. The logs will sometimes add information and so will SQL Audit, if you have that running. There will be application-based metrics too to add to the mix. You can often get information from cache, especially the queries. There will be other forensics, which vary between releases such as those in the Logs (SQL Server Forensics is a science that an average geek like me just marvels at, but at a distance).
With these SQL Monitor custom metrics in place, you can query the Extended Event and default trace data underling each one, as I demonstrated in the previous articles. What you really want though, when you are doing your quick, preliminary investigations, is not the deep details of each one, but the narrative of the changes made, in chronological sequence. It is just like the movies where the young geek, surrounded by stern-faced executives, goes clickety-clack on the keyboard, and immediately the truth is revealed: the scrolling screen flickers, lighting up the grim horror on their faces.
The strategy
Here, we’ll take the Extended events metrics we created and the one that we based on the default trace, and blend them together, just using one of the many columns returned, called action
, which a string that tries to recount, in plain English, what the event or trace actually meant. Since the values are simple strings, it is easy to UNION
them together, and then sort by the date
column that provides the local time.
The procedures from which we get the data are slightly more tortuous than one might expect because I wanted each source to be an inline table function. This makes it much easier to UNION
together the results and order then by the time and date in which they happened. This meant extracting the routines that fetched the ring-buffer information into variables at the start of the batch.
The functions
We start by creating the functions. A quick glance will tell you why we want to hide all that stuff in a function; it isn’t a pretty sight.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 |
IF Object_Id('dbo.SeeAccessControlChanges') IS NOT NULL DROP function dbo.SeeAccessControlChanges GO CREATE FUNCTION dbo.SeeAccessControlChanges /** Summary: > This function gives you a list of security events concerning users, roles and logins from the default trace Author: Phil Factor Date: 04/10/2018 Examples: - Select * from dbo.SeeAccessControlChanges(DateAdd(day,-1,SysDateTime()),SysDateTime()) columns: datetime_local, action, data, hostname, ApplicationName, LoginName, traceName, spid, EventClass, objectName, rolename, TargetLoginName, category_id, ObjectType Returns: > datetime_local datetime2(7) action nvarchar(816) data ntext hostname nvarchar(256) ApplicationName nvarchar(256) LoginName nvarchar(256) traceName nvarchar(128) spid int EventClass int objectName nvarchar(256) rolename nvarchar(256) TargetLoginName nvarchar(256) category_id smallint ObjectType nvarchar(128) **/ ( @Start DATETIME2,--the start of the period @finish DATETIME2--the end of the period ) RETURNS TABLE --WITH ENCRYPTION|SCHEMABINDING, .. AS RETURN ( SELECT Convert(--correct the date for local time. DATETIME2, SwitchOffset(StartTime, DateName(TzOffset, SysDateTimeOffset())) ) AS datetime_local, 'User '+Coalesce( LoginName+ ' ','unknown ')+ CASE EventSubclass --interpret the subclass for these traces WHEN 1 THEN 'added ' WHEN 2 THEN 'dropped ' WHEN 3 THEN 'granted database access for ' WHEN 4 THEN 'revoked database access from ' ELSE 'did something to ' END+ Coalesce(TargetLoginName,'') + Coalesce( CASE EventSubclass WHEN 1 THEN ' to object ' ELSE ' from object ' end+objectname,databasename) +' using the "'+ApplicationName+ '" application' AS action, Coalesce(TextData,'') AS [data], hostname, ApplicationName, LoginName, TE.name AS traceName, spid, EventClass, objectName, rolename, TargetLoginName, TE.category_id, SysTSV.subclass_name AS ObjectType FROM::fn_trace_gettable(--just use the latest trace (SELECT TOP 1 traces.path FROM sys.traces WHERE traces.is_default = 1), DEFAULT) AS DT LEFT OUTER JOIN sys.trace_events AS TE ON DT.EventClass = TE.trace_event_id LEFT OUTER JOIN sys.trace_subclass_values AS SysTSV ON DT.EventClass = SysTSV.trace_event_id AND DT.ObjectType = SysTSV.subclass_value WHERE StartTime BETWEEN @start AND @finish AND TargetLoginName IS NOT NULL ) GO /* now the code to see object changes */ IF Object_Id('dbo.SeeDatabaseObjectChanges') IS NOT NULL DROP function dbo.SeeDatabaseObjectChanges GO CREATE FUNCTION dbo.SeeDatabaseObjectChanges /** Summary: > This function gives you a list of database object changes that happened between the two dates, taken from the default trace Author: Phil Factor Date: 04/10/2018 Examples: - Select * from dbo.SeeDatabaseObjectChanges(DateAdd(day,-1,SysDateTime()),SysDateTime()) Columns: datetime_local, action, databaseID, TransactionID, Hostname, ApplicationName, LoginName, spid, objectid Returns: > datetime_local datetime action nvarchar(4000) databaseID int TransactionID bigint Hostname nvarchar(256) ApplicationName nvarchar(256) LoginName nvarchar(256) spid int objectid int **/ ( @Start DATETIME2,--the start of the period @finish DATETIME2--the end of the period ) RETURNS TABLE --WITH ENCRYPTION|SCHEMABINDING, .. AS RETURN ( SELECT Convert(--correct the date for local time. DATETIME2, SwitchOffset(StartTime, DateName(TzOffset, SysDateTimeOffset())) ) AS datetime_local, 'User '+Coalesce(SessionLoginName,loginName,'') +' '+ Replace(name, 'Object:','') +Coalesce(' '+objtype,'')+' '+ Coalesce(DatabaseName+'.'+ObjectName,databasename) +' using the "'+ApplicationName+ '" application' AS action, databaseID, TransactionID, Hostname, ApplicationName, LoginName, spid,objectid FROM::fn_trace_gettable(--just use the latest trace (SELECT TOP 1 traces.path FROM sys.traces WHERE traces.is_default = 1), DEFAULT) AS DT LEFT OUTER JOIN sys.trace_events AS TE ON DT.EventClass = TE.trace_event_id LEFT OUTER JOIN sys.trace_subclass_values AS SysTSV ON DT.EventClass = SysTSV.trace_event_id AND DT.ObjectType = SysTSV.subclass_value LEFT OUTER JOIN ( VALUES(8259, 'Check Constraint'),( 8260, 'Default (constraint or standalone)'),( 8262, 'Foreign-key Constraint'),( 8272, 'Stored Procedure'), ( 8274, 'Rule'),( 8275, 'System Table'),( 8276, 'Trigger on Server'),( 8277, 'User Table'),( 8278, 'View'), ( 8280, 'Extended Stored Procedure'),(16724, 'CLR Trigger'),(16964, 'Database'),(16975, 'Object'),(17222, 'FullText Catalog'), (17232, 'CLR Stored Procedure'),(17235, 'Schema'),(17475, 'Credential'),(17491, 'DDL Event'),(17741, 'Management Event'), (17747, 'Security Event'),(17749, 'User Event'),(17985, 'CLR Aggregate Function'),(17993, 'Inline Table-valued SQL Function'), (18000, 'Partition Function'),(18002, 'Replication Filter Procedure'),(18004, 'Table-valued SQL Function'),(18259, 'Server Role'), (18263, 'Microsoft Windows Group'),(19265, 'Asymmetric Key'),(19277, 'Master Key'),(19280, 'Primary Key'),(19283, 'ObfusKey'), (19521, 'Asymmetric Key Login'),(19523, 'Certificate Login'),(19538, 'Role'),(19539, 'SQL Login'),(19543, 'Windows Login'), (20034, 'Remote Service Binding'),(20036, 'Event Notification on Database'),(20037, 'Event Notification'),(20038, 'Scalar SQL Function'), (20047, 'Event Notification on Object'),(20051, 'Synonym'),(20549, 'End Point'),(20801, 'Adhoc Queries which may be cached'), (20816, 'Prepared Queries which may be cached'),(20819, 'Service Broker Service Queue'),(20821, 'Unique Constraint'), (21057, 'Application Role'),(21059, 'Certificate'),(21075, 'Server'),(21076, 'Transact-SQL Trigger'),(21313, 'Assembly'), (21318, 'CLR Scalar Function'),(21321, 'Inline scalar SQL Function'),(21328, 'Partition Scheme'),(21333, 'User'), (21571, 'Service Broker Service Contract'),(21572, 'Trigger on Database'),(21574, 'CLR Table-valued Function'), (21577, 'Internal Table (For example, XML Node Table, Queue Table.)'),(21581, 'Service Broker Message Type'),(21586, 'Service Broker Route'), (21587, 'Statistics'),(21825, 'User'),(21827, 'User'),(21831, 'User'),(21843, 'User'),(21847, 'User'),(22099, 'Service Broker Service'), (22601, 'Index'),(22604, 'Certificate Login'),(22611, 'XMLSCHEMA'),(22868, 'Type (e.g. Table Type)'))f(objectTypeid, ObjType) ON dt.objectType=objecttypeid WHERE StartTime BETWEEN @start AND @finish AND databasename NOT IN ('tempdb', 'MASTER') AND EventSubClass=0 AND category_id=5 ) GO /* Here, we show our suspect error messages with all the extra detail we need in order to see what is going on. */ IF Object_Id('dbo.SeeSuspectErrors') IS NOT NULL DROP function dbo.SeeSuspectErrors GO CREATE FUNCTION dbo.SeeSuspectErrors /** Summary: > This function gives you a list of database errors that you would see in development but not so usually in a properly-tested production system. They are typical of the sort of errors that intruders make deliberately to explore and understand the databaase they are attcking. It gives a narrative of what happened between the two dates, taken from the ring buffer Author: Phil Factor Date: 04/10/2018 Examples: > Select * from SeeSuspectErrors( @TheRingBuffer, '2018/11/01',getdate()) Returns: > **/ ( @TargetDataSuspiciousErrors XML,--the ring buffer for the session @Start DATETIME2,--the start of the period @finish DATETIME2--the end of the period ) RETURNS TABLE --WITH ENCRYPTION|SCHEMABINDING, .. AS RETURN ( SELECT datetime_local, UserName+' had error "'+Message+'" ('+Convert(VARCHAR(10),[Error_Number]) +') with query "'+SQL_Text+'" using the "'+ client_app_name+'" application' AS "Action" from (SELECT CONVERT(datetime2, SwitchOffset(CONVERT(datetimeoffset,xed.event_data.value('(@timestamp)[1]', 'datetime2')), DateName(TzOffset, SYSDATETIMEOFFSET()))) AS datetime_local, xed.event_data.value('(data[@name="error_number"]/value)[1]', 'int') AS [Error_Number], xed.event_data.value('(data[@name="severity"]/value)[1]', 'int') AS Severity, xed.event_data.value('(data[@name="message"]/value)[1]', 'varchar(255)') AS [Message], xed.event_data.value('(action[@name="username"]/value)[1]', 'varchar(255)') AS UserName, xed.event_data.value('(action[@name="nt_username"]/value)[1]', 'varchar(255)') AS NT_Username, xed.event_data.value('(action[@name="sql_text"]/value)[1]', 'nvarchar(max)') AS SQL_Text, xed.event_data.value('(action[@name="database_name"]/value)[1]', 'varchar(255)') AS [Database_Name], xed.event_data.value('(action[@name="client_connection_id"]/value)[1]', 'varchar(255)') AS client_conn, xed.event_data.value('(action[@name="client_app_name"]/value)[1]', 'varchar(255)') AS client_app_name FROM @TargetDataSuspiciousErrors.nodes('//RingBufferTarget/event') AS xed (event_data) WHERE Convert(DATETIME2, SwitchOffset( Convert(DATETIMEOFFSET, xed.event_data.value('(@timestamp)[1]', 'datetime2')), DateName(TzOffset, SysDateTimeOffset()) ) ) BETWEEN @start AND @finish )f ) GO IF Object_Id('dbo.SeeConfigChanges') IS NOT NULL DROP function dbo.SeeConfigChanges GO /* Finally, we pick up the config changes */ CREATE FUNCTION dbo.SeeConfigChanges /** Summary: > This function gives you a list of configuration changes that you might see in development but not so usually in a properly-tied-down production system. These configuration changes are done by attackers to allow them to send their payload off remotely. It gives a narrative of what happened between the two dates, taken from the ring buffer Author: Phil Factor Date: 04/10/2018 Examples: > Select * from SeeConfigChanges( @TheRingBuffer,'2018/11/01',getdate()) Returns: > **/ ( @TargetDataChangesInConfigItems XML,--the ring buffer for the session @Start DATETIME2,--the start of the period @finish DATETIME2--the end of the period ) RETURNS TABLE --WITH ENCRYPTION|SCHEMABINDING, .. AS RETURN ( SELECT datetime_local, UserName+' changed ' +Replace(Replace(Message,' Run the RECONFIGURE statement to install.',''),'changed','') +' ('+Convert(VARCHAR(10),[Error_Number])+') with query "'+SQL_Text+'" using the "' + client_app_name+'" application' AS [action] from (SELECT CONVERT(datetime2, SwitchOffset(CONVERT(datetimeoffset,xed.event_data.value('(@timestamp)[1]', 'datetime2')), DateName(TzOffset, SYSDATETIMEOFFSET()))) AS datetime_local, xed.event_data.value('(data[@name="error_number"]/value)[1]', 'int') AS [Error_Number], xed.event_data.value('(data[@name="severity"]/value)[1]', 'int') AS Severity, xed.event_data.value('(data[@name="message"]/value)[1]', 'varchar(255)') AS [Message], xed.event_data.value('(action[@name="username"]/value)[1]', 'varchar(255)') AS UserName, xed.event_data.value('(action[@name="nt_username"]/value)[1]', 'varchar(255)') AS NT_Username, xed.event_data.value('(action[@name="sql_text"]/value)[1]', 'nvarchar(max)') AS SQL_Text, xed.event_data.value('(action[@name="database_name"]/value)[1]', 'varchar(255)') AS [Database_Name], xed.event_data.value('(action[@name="client_connection_id"]/value)[1]', 'varchar(255)') AS client_conn, xed.event_data.value('(action[@name="client_app_name"]/value)[1]', 'varchar(255)') AS client_app_name FROM @TargetDataChangesInConfigItems.nodes('//RingBufferTarget/event') AS xed (event_data) where Convert(DATETIME2, SwitchOffset( Convert(DATETIMEOFFSET, xed.event_data.value('(@timestamp)[1]', 'datetime2')), DateName(TzOffset, SysDateTimeOffset()) ) ) BETWEEN @start AND @finish )f ) Go |
The batch
With all these in place, all we need to do is to decide on the time period we need to investigate and see what has been going on that might be suspicious. First, we pick up the ring buffers and then we execute the following code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
DECLARE @SuspiciousErrors XML = ( SELECT TOP 1 Cast(xet.target_data AS XML) AS targetdata FROM sys.dm_xe_session_targets AS xet INNER JOIN sys.dm_xe_sessions AS xes ON xes.address = xet.event_session_address WHERE xes.name = 'MonitorSuspiciousErrors' AND xet.target_name = 'ring_buffer' ); DECLARE @ChangesInConfigItems XML = ( SELECT TOP 1 Cast(xet.target_data AS XML) AS targetdata FROM sys.dm_xe_session_targets AS xet INNER JOIN sys.dm_xe_sessions AS xes ON xes.address = xet.event_session_address WHERE xes.name = 'ConfigurationItemsChanged' AND xet.target_name = 'ring_buffer' ); DECLARE @StartTime DATETIME, @endTime DATETIME SELECT @StartTime=DateAdd(DAY, -20, SysDateTime()),@EndTime=SysDateTime() SELECT @StartTime='2018-12-04',@EndTime='2018-12-04 10:40' SELECT datetime_local, action FROM dbo.SeeDatabaseObjectChanges(@StartTime, @EndTime) AS objectChanges UNION ALL SELECT datetime_local, action FROM dbo.SeeAccessControlChanges(@StartTime, @EndTime) AS PermissionChanges UNION ALL SELECT datetime_local, action FROM dbo.SeeSuspectErrors(@SuspiciousErrors, @StartTime, @EndTime) UNION ALL SELECT datetime_local, action FROM dbo.SeeConfigChanges(@ChangesInConfigItems,@StartTime, @EndTime) ORDER BY datetime_local; |
This produces a narrative of events that one wouldn’t hope to see in a production server. In our case, it looks as if someone has been up to no good.
The narrative, line by line
I generated all these changes using the NodeJS interface, and PowerShell ‘attack’ script, described in this article. Here, I’ll simply walk through the narrative of the changes, as reported above, and explain briefly what they mean.
That first line says:
That node application is running under my user ID. Via that interface I’ve simply submitted a value that isn’t an integer. It’s been accepted, SQL Server has tried to procedure it, and it’s failed with a 207 error, which was detected by our Suspicious Errors custom monitor. That is a vulnerability.
That next line…
The user has injected some SQL. OK, he or she didn’t do anything effective but didn’t have to because they can see the error and will know it successfully injected SQL. It won’t execute anything because there is a bracket in the filter condition for the first SQL Statement.
In the next line, we see that the attacker has added the bracket but has forgotten to comment out the original bracket, after the part of the code where he injected his attack code.
In the next line, the attacker comments-out that terminating bracket, and SQL Server now tries to execute the second query.
Eek. The attacker now has a way in, and the damage done will depend on how easily he can broaden the scope of the attack. Cast your eye down further down the narrative and it looks bad.
The next line, for example, shows that he got in and added a new principal.
Oh, my giddy aunt. Now on the next line …
He now has sysadmin access using a SQL Server login. Our custom monitor for changes to logins, roles users and permissions has detected it.
Oo-er. Now things get even scarier…
He’s enabled Ad Hoc Distributed Queries
configuration setting. He is looking for ways of getting a large payload off the server! On the next line, he achieves this!
Now he is opening up xp_cmdShell
!
He is now going to use xp_cmdShell
for something. FTP? Our custom monitor for SQL Server configuration changes picks up this suspicious activity.
Next, the attacker is trying to create a temporary table. Why? The name #directorylisting
is a clue. He’s probably trying to store and then retrieve all sorts of useful information you can get from executing OS commands and procedures.
That was a silly mistake. He didn’t need that error. He forgot the comment. He’s trying to use a global temporary table, but it isn’t working for him. Probably he is the only connection at this point and the connection is getting closed in the connection pool.
The next line tells us that he’s now using an ordinary table to do the job.
And the next line …
…proves that it was successful. Heaven only knows what information is in it, but it will be in the log. Our Database Drift custom metric alerted us to these changes, at least.
Now it looks as if our attacker has got everything he needs and is just tidying up.
Yes, he has injected a script that has gone ahead and tried to remove the traces of the attack. Luckily, we have the Extended Events session data to tell us.
So, it all happened very quickly. In this case, it was a scripted attack, which you can emulate using the NodeJs interface and PowerShell script described here. Real probing attacks can go on for hours, and you can see the early stages of a rash of syntax errors that are inevitable in the early stages of any attack.
Once you see the escalation to sysadmin privileges and those configuration changes, you know that the attacker has full control. You must now assume that the attacker has all your tables dumped out and dispatched to an FTP site somewhere in eastern Europe. In this case, every table from Adventureworks
was first saved to the filesystem and then dispatched to a remote FTP site as soon as the attacker had opened up xp_cmdShell
.
Conclusion
For anyone of a nervous or sensitive disposition, I must emphasize that this is a simulation, and in another article, we’ll go through the PowerShell script and application scenario that did this. You will see that the attacker very soon had access to what could, in reality, have been sensitive private information, and could have gone on to inflict a lot more damage. Even so, the entire AdventureWorks data was leaked.
The script we introduced in this article will give you clues to what has gone on, but it is only a pointer to what you need to drill down to. Its purpose is just to alert you to suspicious activity. It won’t give you the full narrative. You will need to know quickly what cached plans are there that will reveal the queries that were executed. That is another story!
Tools in this post
Redgate Monitor
Real-time SQL Server and PostgreSQL performance monitoring, with alerts and diagnostics