In June 2024, it was discovered that a targeted threat campaign by a financially motivated threat actor (tracked by the name “UNC5537”) was conducted against Snowflake customers. Per the investigations conducted by Mandiant and Snowflake, it was identified that multiple Snowflake customers were affected by an info-stealer malware, which led to stolen credentials prior to the unauthorized access to the organizational Snowflake account. The threat actor was able to leverage these credentials as there was no MFA enabled on these accounts.
While many blog posts and publications have outlined basic threat-hunting queries related to this breach, we aim to provide advanced technical insights into an unverified Hunter’s response to this threat campaign. Our approach includes sophisticated hunting efforts that leverage Snowflake's statistical and analytical functionalities, as well as comprehensive follow-up investigations to ensure our customers' security. This blog post will showcase not just standard queries, but advanced techniques and methodologies that elevate threat-hunting and investigation capabilities beyond the basics.
Our insights can be used as part of security teams’ hunting and investigation efforts to make sure their Snowflake accounts are safe.
It is important to not only hunt for and investigate threats but to also carry out proactive measures to ensure the safety of your Snowflake accounts. You can utilize the Snowflake Trust Center to assess and monitor your account for security risks.
At the beginning of this incident, we had some specific pieces of information, including the following:
Using the information above, we initiated threat-hunting and investigation efforts. First, we collected and created a set of Snowflake threat-hunting queries, including queries to look for the specific IOCs mentioned above. The additional queries were to look for different characteristics, such as suspicious clients, unauthorized login attempts, different exfiltration methods, suspicious daily error rates, spikes in user queries volume, etc.
Following the hunting efforts, we investigated specific hunting “hits” that stood out, to make sure there were no malicious activities among them. The following section includes a human-friendly, in-depth explanation regarding some of the main hunting queries we used.
As part of the threat-hunting campaign, we used a variety of queries to hunt for different types of malicious activities, in addition to the specific IOC lookups conducted directly based on the IOC publications by Snowflake.
In this section we provide a few examples of the queries we used, and an in-depth explanation regarding each one of them, to make sure anyone who uses them fully understands the rationale behind them and can use those explanations to better understand other Snowflake-related hunting queries.
HUNTING QUERY 1: Suspicious Snowflake Sessions - Unusual Application
WITH PREVIOUSLY_USED_APPS AS (
SELECT DISTINCT PARSE_JSON(client_environment) :APPLICATION::STRING AS PREV_CLIENT_APPS
FROM SNOWFLAKE.ACCOUNT_USAGE.SESSIONS
WHERE CREATED_ON >= '2023-12-01'
AND CREATED_ON < '2024-03-01'
AND PREV_CLIENT_APPS IS NOT NULL
)
SELECT PARSE_JSON(client_environment) :APPLICATION::STRING AS CLIENT_APPLICATION,
PARSE_JSON(client_environment) :OS::STRING AS CLIENT_OS,
PARSE_JSON(client_environment) :OS_VERSION::STRING AS CLIENT_OS_VERSION,
ARRAY_AGG(SESSIONS.SESSION_ID) AS SESSION_IDS,
ARRAY_AGG(SESSIONS.LOGIN_EVENT_ID) AS LOGIN_EVENT_IDS,
MIN(created_on) AS MIN_CREATED_ON,
MAX(CREATED_ON) AS MAX_CREATED_ON,
COUNT(*) AS COUNTER
FROM SNOWFLAKE.ACCOUNT_USAGE.SESSIONS sessions
WHERE 1 = 1
AND sessions.created_on >= '2024-03-01'
AND CLIENT_APPLICATION NOT IN (
SELECT *
FROM PREVIOUSLY_USED_APPS
)
GROUP BY 1, 2, 3
HAVING COUNTER < 50
ORDER BY 1 ASC;
HUNTING QUERY 2: Snowflake Daily Error Rate Anomaly
WITH error_stats AS (
SELECT date_trunc('day',START_TIME) AS DATE,
USER_NAME AS USER_NAME,
COUNT(*) AS ERROR_COUNT
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE ERROR_CODE IS NOT NULL
AND START_TIME >= current_timestamp - interval '4 months'
AND ERROR_MESSAGE <> 'Amount of data in Snowflake internal staging area exceeds the soft limit. Please remove unneeded files or contact Snowflake Support about raising the limit.'
AND NOT ERROR_MESSAGE LIKE 'SQL compilation error%'
GROUP BY date, USER_NAME
),
total_queries AS (
SELECT date_trunc('day',START_TIME) AS DATE,
USER_NAME,
COUNT(*) AS TOTAL_QUERIES
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE START_TIME >= current_timestamp - interval '4 months'
GROUP BY DATE, USER_NAME
),
final_stats AS (
SELECT tq.DATE,
tq.USER_NAME,
tq.TOTAL_QUERIES,
COALESCE(es.error_count, 0) AS ERROR_COUNT,
(COALESCE(es.error_count, 0) / tq.total_queries) * 100 AS DAILY_ERROR_PERCENTAGE
FROM total_queries tq
LEFT JOIN error_stats es ON tq.date = es.date AND tq.USER_NAME = es.USER_NAME
)
SELECT * FROM final_stats;
Both views are derived from the QUERY_HISTORY table that Snowflake generates.
In the “total_queries” CTE we:
Finally, we join these two views together by the day and username to pair the daily error count to the daily total query count and display the total percentage of the errors from the total query count for each user per day.
(Note: we are using the COALESCE function to return 0 in case a NULL value is found in any of the subqueries.)
HUNTING QUERY 3: Potential exfiltration based on spikes in user queries volume
WITH USER_DAILY_BYTES AS (
SELECT USER_NAME AS USER_NAME,
DATE_TRUNC('DAY', END_TIME) AS QUERY_DATE,
QUERY_TEXT,
BYTES_WRITTEN_TO_RESULT,
SUM(BYTES_WRITTEN_TO_RESULT) OVER (PARTITION BY USER_NAME, DATE_TRUNC('DAY',END_TIME)) AS TOTAL_BYTES_WRITTEN,
ROW_NUMBER() OVER (PARTITION BY USER_NAME, DATE_TRUNC('DAY', END_TIME) ORDER BY BYTES_WRITTEN_TO_RESULT DESC) AS ROW_NUMBER
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE END_TIME >= CURRENT_TIMESTAMP() - INTERVAL '30 DAY'
),
TOP_QUERY_PER_DAY AS (
SELECT USER_NAME,
QUERY_DATE,
QUERY_TEXT,
BYTES_WRITTEN_TO_RESULT,
TOTAL_BYTES_WRITTEN
FROM USER_DAILY_BYTES
WHERE ROW_NUMBER = 1
),
USER_DAILY_AVERAGE AS (
SELECT USER_NAME,
AVG(TOTAL_BYTES_WRITTEN) AS AVG_BYTES_WRITTEN,
STDDEV_SAMP(total_bytes_written) AS STDDEV_BYTES_WRITTEN
FROM TOP_QUERY_PER_DAY
GROUP BY USER_NAME
)
SELECT u.USER_NAME,
u.QUERY_DATE,
u.QUERY_TEXT AS TOP_QUERY_TEXT,
ROUND(u.BYTES_WRITTEN_TO_RESULT, 2) AS TOP_QUERY_BYTES_WRITTEN,
ROUND(u.TOTAL_BYTES_WRITTEN, 2) AS TOTAL_BYTES_WRITTEN,
ROUND(a.AVG_BYTES_WRITTEN, 2) AS AVG_DAILY_BYTES,
ROUND(a.STDDEV_BYTES_WRITTEN, 2) AS STDDEV_DAILY_BYTES
FROM TOP_QUERY_PER_DAY u
JOIN USER_DAILY_AVERAGE a
ON u.USER_NAME = a.USER_NAME
WHERE u.TOTAL_BYTES_WRITTEN > a.AVG_BYTES_WRITTEN
AND u.TOTAL_BYTES_WRITTEN > a.AVG_BYTES_WRITTEN + 3 * a.stddev_bytes_written
AND u.TOTAL_BYTES_WRITTEN > 1000000 -- Minimum threshold (1 MB)
ORDER BY u.USER_NAME, u.QUERY_DATE;
HUNTING QUERY 4: Authentication attempts to a locked/disabled user account
SELECT EVENT_TIMESTAMP AS TIMESTAMP,
EVENT_TYPE AS EVENT_TYPE,
USER_NAME AS USER_NAME,
CLIENT_IP AS CLIENT_IP,
REPORTED_CLIENT_TYPE AS CLIENT_TYPE,
REPORTED_CLIENT_VERSION AS CLIENT_VERSION,
IS_SUCCESS AS IS_SUCCESSFULL_LOGIN,
FIRST_AUTHENTICATION_FACTOR AS FIRST_AUTH_FACTOR,
SECOND_AUTHENTICATION_FACTOR AS SECOND_AUTH_FACTOR,
ERROR_MESSAGE AS ERROR_MESSAGE,
ERROR_CODE AS ERROR_CODE
FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY
WHERE IS_SUCCESS = 'NO'
AND ERROR_MESSAGE = 'USER_ACCESS_DISABLED'
AND event_timestamp >= current_timestamp - interval '8 months'
SELECT READER_ACCOUNT_NAME AS READER_ACCOUNT_NAME,
EVENT_TIMESTAMP AS TIMESTAMP,
EVENT_TYPE AS EVENT_TYPE,
USER_NAME AS USER_NAME,
CLIENT_IP AS CLIENT_IP,
REPORTED_CLIENT_TYPE AS CLIENT_TYPE,
REPORTED_CLIENT_VERSION AS CLIENT_VERSION,
IS_SUCCESS AS IS_SUCCESSFULL_LOGIN,
FIRST_AUTHENTICATION_FACTOR AS FIRST_AUTH_FACTOR,
SECOND_AUTHENTICATION_FACTOR AS SECOND_AUTH_FACTOR,
ERROR_MESSAGE AS ERROR_MESSAGE,
ERROR_CODE AS ERROR_CODE
FROM SNOWFLAKE.READER_ACCOUNT_USAGE.LOGIN_HISTORY
WHERE IS_SUCCESS = 'NO'
AND ERROR_MESSAGE = 'USER_ACCESS_DISABLED'
AND event_timestamp >= current_timestamp - interval '8 months'
One aspect we wanted to highlight is the importance of covering Reader Account’s logs as part of hunting/investigation efforts.
Reader accounts are designed to provide read-only access to a Snowflake database shared by a Snowflake “provider.” Imagine a multi-tenant cloud setup where each customer operates their own distinct database, all managed by a central vendor. If you, as the vendor, wish to grant read access to the underlying data lake, you would utilize a reader account.
For organizations functioning as Snowflake providers, where reader accounts are employed to facilitate data access, it is essential to include the logs from these accounts in your hunting and investigation routines. Ignoring them could leave significant gaps in your threat detection strategy.
The logs related to Reader accounts can be found in dedicated log tables. For example, in Team Axon’s UNC5537-focused threat-hunting /investigations, the following tables were used:
By looking at those tables we made sure that potential malicious activities related to Reader Accounts will be identified by the threat-hunting efforts conducted.
As part of threat-hunting, one of the most important parts is of course the follow-up investigation of interesting hits. This section includes some questions that should be asked by analysts when investigating suspicious hits that came up in Snowflake threat-hunting efforts, followed by some examples of investigation queries that can be used throughout the investigation of different types of threat-hunting hits.
Main questions:
Queries that can be used as part of Snowflake investigation efforts:
QUERY 1: Logon information based on suspicious Session IDs
In case of identification of a suspicious Snowflake session, one of the first things we would like to do is get the relevant login information related to this session.
This logon information is important for us to get additional characteristics related to the user and application used for this session.
Here are the important pieces of information you can get using this investigation query:
SELECT SESSION_TABLE_1.CREATED_ON AS SESSION_CREATION_TIME,
PARSE_JSON(SESSION_TABLE_1.client_environment):APPLICATION::STRING AS SESSION_CLIENT_APPLICATION,
PARSE_JSON(SESSION_TABLE_1.client_environment):OS::STRING AS SESSION_CLIENT_OS,
PARSE_JSON(SESSION_TABLE_1.client_environment):OS_VERSION::STRING AS SESSION_CLIENT_OS_VERSION,
SESSION_TABLE_1.SESSION_ID AS SESSION_ID,
SESSION_TABLE_1.USER_NAME AS USER_NAME,
SESSION_TABLE_1.AUTHENTICATION_METHOD AS SESSION_AUTHENTICATION_METHOD,
SESSION_TABLE_1.LOGIN_EVENT_ID AS LOGIN_EVENT_ID,
SESSION_TABLE_1.CLIENT_APPLICATION_ID AS SESSION_CLIENT_APPLICATION_ID,
SESSION_TABLE_1.CLOSED_REASON AS SESSION_CLOSED_REASON,
LOGIN_HISTORY_2.EVENT_ID AS LOGIN_HISTORY_EVENT_ID,
LOGIN_HISTORY_2.EVENT_TIMESTAMP AS LOGIN_EVENT_TIME,
LOGIN_HISTORY_2.EVENT_TYPE AS LOGIN_EVENT_TYPE,
LOGIN_HISTORY_2.CLIENT_IP AS LOGIN_CLIENT_IP,
LOGIN_HISTORY_2.REPORTED_CLIENT_TYPE AS LOGIN_REPORTED_CLIENT_TYPE,
LOGIN_HISTORY_2.REPORTED_CLIENT_VERSION AS LOGIN_REPORTED_CLIENT_VERSION,
LOGIN_HISTORY_2.FIRST_AUTHENTICATION_FACTOR AS LOGIN_FIRST_AUTH_FACTOR,
LOGIN_HISTORY_2.SECOND_AUTHENTICATION_FACTOR AS LOGIN_SECOND_AUTH_FACTOR,
LOGIN_HISTORY_2.IS_SUCCESS AS SUCCESSFULL_LOGIN,
LOGIN_HISTORY_2.ERROR_MESSAGE AS LOGIN_ERROR_MESSAGE,
LOGIN_HISTORY_2.ERROR_CODE AS LOGIN_ERROR_CODE
FROM SNOWFLAKE.ACCOUNT_USAGE.SESSIONS AS SESSION_TABLE_1
JOIN SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY AS LOGIN_HISTORY_2 ON SESSION_TABLE_1.LOGIN_EVENT_ID = LOGIN_HISTORY_2.EVENT_ID
-- Modify timestamps according to investigation timeframe
WHERE LOGIN_HISTORY_2.EVENT_TIMESTAMP BETWEEN '2024-01-01 00:00:00' and '2024-06-22 22:30:00'
-- Modify timestamps according to investigation timeframe
AND SESSION_TABLE_1.CREATED_ON BETWEEN '2024-01-01 00:00:00' and '2024-06-22 22:30:00'
-- Insert the Login Event ID of suspicious session(s) to get the relevant login details.
AND SESSION_TABLE_1.LOGIN_EVENT_ID IN ('')
QUERY 2: Get relevant Session information and Login information related to suspicious queries identified
A convenient way to quickly gather relevant session details and login information of suspicious queries identified.
SELECT QUERY_HISTORY_3.QUERY_TEXT AS QUERY_TEXT,
QUERY_HISTORY_3.QUERY_TAG AS QUERY_TAG,
QUERY_HISTORY_3.START_TIME AS QUERY_TIME,
QUERY_HISTORY_3.QUERY_ID AS QUERY_ID,
QUERY_HISTORY_3.QUERY_TYPE AS QUERY_TYPE,
QUERY_HISTORY_3.ROLE_NAME AS ROLE_NAME,
QUERY_HISTORY_3.EXECUTION_STATUS AS EXECUTION_STATUS,
QUERY_HISTORY_3.ROWS_WRITTEN_TO_RESULT AS RESULTS_ROWS_WRITTEN,
QUERY_HISTORY_3.BYTES_WRITTEN_TO_RESULT AS RESULTS_BYTES_WRITTEN,
SESSIONS_TABLE_2.SESSION_ID AS SESSION_ID,
SESSIONS_TABLE_2.CREATED_ON AS SESSION_CREATION_TIME,
PARSE_JSON(SESSIONS_TABLE_2.client_environment):APPLICATION::STRING AS SESSION_CLIENT_APPLICATION,
PARSE_JSON(SESSIONS_TABLE_2.client_environment):OS::STRING AS SESSION_CLIENT_OS,
PARSE_JSON(SESSIONS_TABLE_2.client_environment):OS_VERSION::STRING AS SESSION_CLIENT_OS_VERSION,
SESSIONS_TABLE_2.SESSION_ID AS SESSION_ID,
SESSIONS_TABLE_2.USER_NAME AS USER_NAME,
SESSIONS_TABLE_2.AUTHENTICATION_METHOD AS SESSION_AUTHENTICATION_METHOD,
SESSIONS_TABLE_2.LOGIN_EVENT_ID AS LOGIN_EVENT_ID,
SESSIONS_TABLE_2.CLIENT_APPLICATION_ID AS SESSION_CLIENT_APPLICATION_ID,
SESSIONS_TABLE_2.CLOSED_REASON AS SESSION_CLOSED_REASON,
LOGIN_HISTORY_1.EVENT_ID AS LOGIN_HISTORY_EVENT_ID,
LOGIN_HISTORY_1.EVENT_TIMESTAMP AS LOGIN_EVENT_TIME,
LOGIN_HISTORY_1.EVENT_TYPE AS LOGIN_EVENT_TYPE,
LOGIN_HISTORY_1.CLIENT_IP AS LOGIN_CLIENT_IP,
LOGIN_HISTORY_1.REPORTED_CLIENT_TYPE AS LOGIN_REPORTED_CLIENT_TYPE,
LOGIN_HISTORY_1.REPORTED_CLIENT_VERSION AS LOGIN_REPORTED_CLIENT_VERSION,
LOGIN_HISTORY_1.FIRST_AUTHENTICATION_FACTOR AS LOGIN_FIRST_AUTH_FACTOR,
LOGIN_HISTORY_1.SECOND_AUTHENTICATION_FACTOR AS LOGIN_SECOND_AUTH_FACTOR,
LOGIN_HISTORY_1.IS_SUCCESS AS SUCCESSFULL_LOGIN
from SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY AS LOGIN_HISTORY_1
join snowflake.account_usage.SESSIONS AS SESSIONS_TABLE_2 ON EVENT_ID = LOGIN_EVENT_ID
join snowflake.ACCOUNT_USAGE.QUERY_HISTORY AS QUERY_HISTORY_3 ON QUERY_HISTORY_3.SESSION_ID = SESSIONS_TABLE_2.SESSION_ID
where SESSIONS_TABLE_2.SESSION_ID IN (
select DISTINCT SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY.SESSION_ID
from SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
-- Modify Query text to look for based on investigated activity
where (REGEXP_LIKE(QUERY_TEXT, 'create\\s+share\\s.*','i') or REGEXP_LIKE(QUERY_TEXT, '\\s+to\\s+share\\s.*','i'))
-- Modify timestamps according to investigation timeframe
AND START_TIME BETWEEN '2024-04-01 00:00:00' and '2024-05-01 00:00:00')
AND LOGIN_HISTORY_1.EVENT_TIMESTAMP > '2024-03-20 00:00:05.188000 +00:00'
AND QUERY_HISTORY_3.START_TIME BETWEEN '2024-04-01 00:00:00.188000 +00:00' AND '2024-05-01 15:26:05.188000 +00:00'
AND SESSIONS_TABLE_2.CREATED_ON > '2024-03-20 00:00:05.188000 +00:00'
ORDER BY QUERY_HISTORY_3.START_TIME ASC;
QUERY 3: Get relevant queries and Session IDs based on Login ID
Can be used to explore the activities that were conducted under the context of a specific suspicious login. For example, a case in which login event from a malicious/unknown IP address had been conducted.
SELECT ---- Login Information
LOGIN_HISTORY_1.EVENT_ID AS LOGIN_HISTORY_EVENT_ID,
LOGIN_HISTORY_1.EVENT_TIMESTAMP AS LOGIN_EVENT_TIME,
LOGIN_HISTORY_1.EVENT_TYPE AS LOGIN_EVENT_TYPE,
LOGIN_HISTORY_1.CLIENT_IP AS LOGIN_CLIENT_IP,
LOGIN_HISTORY_1.REPORTED_CLIENT_TYPE AS LOGIN_REPORTED_CLIENT_TYPE,
LOGIN_HISTORY_1.REPORTED_CLIENT_VERSION AS LOGIN_REPORTED_CLIENT_VERSION,
LOGIN_HISTORY_1.FIRST_AUTHENTICATION_FACTOR AS LOGIN_FIRST_AUTH_FACTOR,
LOGIN_HISTORY_1.SECOND_AUTHENTICATION_FACTOR AS LOGIN_SECOND_AUTH_FACTOR,
LOGIN_HISTORY_1.IS_SUCCESS AS SUCCESSFULL_LOGIN,
---- Session Details
SESSIONS_TABLE_2.SESSION_ID AS SESSION_ID,
SESSIONS_TABLE_2.CREATED_ON AS SESSION_CREATION_TIME,
PARSE_JSON(SESSIONS_TABLE_2.client_environment):APPLICATION::STRING AS SESSION_CLIENT_APPLICATION,
PARSE_JSON(SESSIONS_TABLE_2.client_environment):OS::STRING AS SESSION_CLIENT_OS,
PARSE_JSON(SESSIONS_TABLE_2.client_environment):OS_VERSION::STRING AS SESSION_CLIENT_OS_VERSION,
SESSIONS_TABLE_2.SESSION_ID AS SESSION_ID,
SESSIONS_TABLE_2.USER_NAME AS USER_NAME,
SESSIONS_TABLE_2.AUTHENTICATION_METHOD AS SESSION_AUTHENTICATION_METHOD,
SESSIONS_TABLE_2.LOGIN_EVENT_ID AS LOGIN_EVENT_ID,
SESSIONS_TABLE_2.CLIENT_APPLICATION_ID AS SESSION_CLIENT_APPLICATION_ID,
SESSIONS_TABLE_2.CLOSED_REASON AS SESSION_CLOSED_REASON,
---- Query Details
QUERY_HISTORY_3.QUERY_TEXT AS QUERY_TEXT,
QUERY_HISTORY_3.QUERY_TAG AS QUERY_TAG,
QUERY_HISTORY_3.START_TIME AS QUERY_TIME,
QUERY_HISTORY_3.QUERY_ID AS QUERY_ID,
QUERY_HISTORY_3.QUERY_TYPE AS QUERY_TYPE,
QUERY_HISTORY_3.ROLE_NAME AS ROLE_NAME,
QUERY_HISTORY_3.EXECUTION_STATUS AS EXECUTION_STATUS,
QUERY_HISTORY_3.ROWS_WRITTEN_TO_RESULT AS RESULTS_ROWS_WRITTEN,
QUERY_HISTORY_3.BYTES_WRITTEN_TO_RESULT AS RESULTS_BYTES_WRITTEN
FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY AS LOGIN_HISTORY_1
JOIN SNOWFLAKE.ACCOUNT_USAGE.SESSIONS AS SESSIONS_TABLE_2 ON LOGIN_HISTORY_1.EVENT_ID = SESSIONS_TABLE_2.LOGIN_EVENT_ID
JOIN SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY QUERY_HISTORY_3 ON SESSIONS_TABLE_2.SESSION_ID = QUERY_HISTORY_3.SESSION_ID
WHERE LOGIN_HISTORY_1.EVENT_ID = ''
-- Modify timestamps according to investigation timeframe
AND LOGIN_HISTORY_1.EVENT_TIMESTAMP BETWEEN '2024-03-12 00:00:00' AND '2024-03-15 00:30:00'
AND SESSIONS_TABLE_2.CREATED_ON BETWEEN '2024-03-12 00:00:00' AND '2024-03-15 00:30:00'
AND QUERY_HISTORY_3.START_TIME BETWEEN '2024-03-12 00:00:00' AND '2024-03-15 00:30:00'
ORDER BY QUERY_TIME ASC
QUERY 4: Correlation of Internal Logon Event (IP: 0.0.0.0) to potential source IP address
During the threat-hunting efforts conducted by the team, we witnessed cases in which the source IP of login events related to some hits of query history-based hunting queries was 0.0.0.0.
An example of a client application that was used in cases in which the source IP address was 0.0.0.0 is “Snowflake Web App (Snowscope sub-service)”. This is a sub-service, which means that the communication originated from Snowflake’s internal application, thus the correlation to previous logon event can be useful.
The following investigation query was used in those cases to try and correlate those Snowscope sub-service login events to the origin source IP used by the actual user.
SELECT DISTINCT
LHT1.EVENT_TIMESTAMP AS LOGON_EVENT_TIME,
DATEADD(HOUR, -6, LOGON_EVENT_TIME) AS LOGON_EVENT_TIME_MIUNS_6_HOUR,
LHT1.USER_NAME AS LOGON_USER_NAME,
LHT1.EVENT_ID AS LOGON_EVENT_ID,
LHT1.FIRST_AUTHENTICATION_FACTOR AS LOGON_FIRST_AUTH_FACTOR,
LHT1.SECOND_AUTHENTICATION_FACTOR AS LOGON_SECOND_AUTH_FACTOR,
PARSE_JSON(ST2.client_environment) :APPLICATION :: STRING AS CLIENT_APPLICATION,
LHT1.CLIENT_IP AS LOGON_CLIENT_IP,
LHT3.EVENT_TIMESTAMP AS PREVIOUS_LOGONS_TIMESTAMP,
LHT3.USER_NAME AS PREVIOUS_LOGONS_USER_NAME,
LHT3.EVENT_ID AS PREVIOUS_LOGON_EVENT_ID,
PARSE_JSON(ST4.client_environment) :APPLICATION :: STRING AS PREVIOUS_LOGONS_CLIENT_APPLICATION,
LHT3.FIRST_AUTHENTICATION_FACTOR AS PREVIOUS_LOGON_FIRST_AUTH_FACTOR,
LHT3.SECOND_AUTHENTICATION_FACTOR AS PREVIOUS_LOGON_SECOND_AUTH_FACTOR,
LHT3.CLIENT_IP AS PREVIOUS_LOGONS_LOGON_CLIENT_IP
FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY AS LHT1
JOIN SNOWFLAKE.ACCOUNT_USAGE.SESSIONS AS ST2 ON ST2.LOGIN_EVENT_ID = LHT1.EVENT_ID
JOIN SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY AS LHT3 ON (LHT3.EVENT_TIMESTAMP BETWEEN LOGON_EVENT_TIME_MIUNS_6_HOUR AND LOGON_EVENT_TIME AND PREVIOUS_LOGONS_USER_NAME = LOGON_USER_NAME)
JOIN SNOWFLAKE.ACCOUNT_USAGE.SESSIONS AS ST4 ON ST4.LOGIN_EVENT_ID = LHT3.EVENT_ID
WHERE LHT1.EVENT_TIMESTAMP BETWEEN '2024-03-01 00:00:00.000000 +00:00' and '2024-05-01 00:30:00.000000 +00:00'
AND ST2.CREATED_ON BETWEEN '2024-03-01 00:00:00.000000 +00:00' and '2024-05-01 00:30:00.000000 +00:00'
AND LHT3.EVENT_TIMESTAMP BETWEEN '2024-03-01 00:00:00.000000 +00:00' and '2024-05-01 00:30:00.000000 +00:00'
AND ST4.CREATED_ON BETWEEN '2024-03-01 00:00:00.000000 +00:00' and '2024-05-01 00:30:00.000000 +00:00'
AND CLIENT_APPLICATION = 'Snowflake Web App (Snowscope sub-service)'
AND PREVIOUS_LOGONS_CLIENT_APPLICATION != 'Snowflake Web App (Snowscope sub-service)'
ORDER BY LOGON_EVENT_TIME ASC
QUERY 5.1 & 5.2: Get info regarding suspicious IP addresses (cross-data sources)
The usage of pivoting is key in many types of investigations including the cases of Snowflake attacks threat-hunting/investigations.
While investigating different threat-hunting hits we used additional data sources to get to a final verdict.
Query 5.1 for example demonstrates a very simple query we used to check if an IP address of suspicious sign-in was part of our “Organizational_IP” schema at Hunters. Getting the indication of prevalence of this IP address usage in the organization is super useful.
Query 5.2 In addition, we also used specific data sources to better understand the characteristics of usage of specific user accounts and their work from a specific source IP address. For example, in the case of a suspicious query that we correlated to a sign-in event from the IP x.x.x.x → we looked for sign-ins of the specific user account in Okta logs, to check two different things:
--- Query 5.1
SELECT IP AS IP_ADDRESS,
ID AS USER_ID,
ORIGINAL_SPECIFIC_SOURCE_TYPE AS SPECIFIC_LOG_SOURCE,
DAYS_SEEN AS NUMBERS_OF_DAYS_SEEN,
BASELINE_START AS BASELINE_START,
BASELINE_END AS BASELINE_END
FROM INVESTIGATION.ORGANIZATIONAL_IP
WHERE IP = ''
--- Query 5.2
SELECT PUBLISHED AS EVENT_TIME,
EVENT_TYPE AS EVENT_TYPE,
DISPLAY_MESSAGE AS DISPLAY_MESSAGE,
ACTOR_DISPLAY_NAME AS ACTOR_DISPLAY_NAME,
ACTOR_ALTERNATE_ID AS ACTOR_ALTERNATE_ID,
CLIENT_IP_ADDRESS AS SOURCE_IP,
CLIENT_DEVICE AS CLIENT_DEVICE,
CLIENT_USER_AGENT_RAW_USER_AGENT AS USER_AGENT
FROM RAW.OKTA_LOGS
WHERE CLIENT_IP_ADDRESS ILIKE ''
AND ACTOR_ALTERNATE_ID ILIKE ''
AND PUBLISHED BETWEEN '2024-03-01 00:00:00' and '2024-07-15 00:30:00'
QUERY 6: Application baseline of a user
Similar to the Logon History baseline of a user mentioned above, a baseline of applications used as part of the user’s sessions can also be useful to understand if the suspicious activity aligns with the baseline or not.
SELECT USER_NAME AS USER_NAME,
MIN(CREATED_ON) AS MIN_CREATED_ON,
MAX(CREATED_ON) AS MAX_CREATED_ON,
ARRAY_AGG(DISTINCT AUTHENTICATION_METHOD) AS AUTH_METHODS,
ARRAY_AGG(DISTINCT CLIENT_APPLICATION_ID) AS CLIENT_APP_IDS,
PARSE_JSON(client_environment):APPLICATION::STRING AS CLIENT_APPLICATION,
PARSE_JSON(client_environment):BROWSER_VERSION::STRING AS BROWSER_VERSION,
COUNT(*) AS COUNTER
FROM SNOWFLAKE.ACCOUNT_USAGE.SESSIONS
---- Insert the user name of interest
WHERE USER_NAME ILIKE ''
---- Modify Timestamp based on investigation timeframe
AND CREATED_ON BETWEEN '2024-06-01 00:00:00' and '2024-07-01 00:30:00'
GROUP BY 1,6,7
In addition to our threat hunting efforts, Hunters offers a detection layer of security that could provide early alerts and coverage if a customer was targeted by UNC5537. This added protection comes from our out-of-the-box detection content. This section outlines one such pre-existing detector that could have flagged the attack during its course and another that was developed in response to the campaign.
New Content: Snowflake Query Awareness Behavior
In response to the campaign, Hunters’ researchers released a new detector based on the behavior and the commands the threat actors executed on the database in the Reconnaissance phase (examples could be seen in the Mandiant report).
From the Hunter's documentation: "Detects an execution of multiple situational awareness database commands in a short period (30 minutes) on a Snowflake account.
Adversaries often use situational awareness commands on databases to discover critical information such as the location of sensitive data, user roles, and permissions, which helps them plan targeted attacks and privilege escalation. By listing and describing databases, tables, and schemas, as well as enumerating permissions, attackers gather insights that enable more effective and precise exploitation.
This detector checks the query_history table from both Snowflake accounts: account usage and reader account usage. The account usage contains auditing of the queries that were executed from this account and reader account usage contains auditing of the queries that were executed, but only the ones that are related to reader accounts.”
Important: The significance of monitoring Reader Account data (in the context of Snowflake) has been discussed in the Technical section. It is crucial to note that this data is covered here, as it is often overlooked but vital for comprehensive security.
Existing Detector: Impossible travel between SaaS logins
From the Hunter's documentation:“Detects anomalous consecutive SaaS logins from two different IP addresses by the same user, with the required traveling speed between them being impossible in the observed time frame. Impossible travel may indicate the logins were not made by the same person, therefore may indicate the user was compromised by a malicious actor.”
If a threat actor had logged into a Snowflake account from an irregular user or a geolocation significantly distant from the previous login location, the following detector would trigger an alert. This notification would prompt security personnel to take action, potentially preventing data exfiltration or other malicious activities before the threat actors could fully exploit their access.
Additional Existing Detectors
While these TTPs were not part of the UNC5537 attack vector, they would be useful in similar attack scenarios:
The following resources were used for the creation of some hunting/investigation queries: