Password Spraying Detection
Full Recipe¶
Shared by: Allan Konar
Ingests JSON-formatted IAM-style password authentication log file and creates relationships to detect Password Spraying attacks.
Password Spraying Detection Recipe
1 |
|
Scenario¶
In this scenario, Quine ingests password-based authentication logs modeled on the top IAM providers (hosted and on-prem) and generates a graph manifesting the following nodes:
- attempt - transaction representing a password authentication attempt
- user - user that originated the attempt
- client - client (computer/mobile/unknown) from which user originated the attempt
- asn - ASN from which user originated the attempt
- asset - asset (server, service, etc.) that the user targeted
- time - time of attempt
The first standing query uses the manifested graph structure to generate synthetic edges between sequential attempts for a user:
(attempt1)-[:NEXT]->(attempt2)-[:NEXT]->(attempt3)
The second standing query looks for four consecutive failed attempts followed by a successful attempt from a user to trigger an alert with a link to the subgraph that represents a potential password spraying attack.
(attempt1 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt2 {outcomeResult:"FAILURE"})-[:NEXT]->
(attempt3 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt4 {outcomeResult:"FAILURE"})-[:NEXT]->
(attempt5 {outcomeResult:"SUCCESS"})-[:USING]->(client4)
Sample Data¶
Ensure that the attempts.json file is in the same directory as Quine and issue the following command to begin:
How it Works¶
A single ingest stream does a lot of work for us to parse each line into multiple nodes.
We use idFrom()
to create the node IDs from each with a unique namespace and event parameters to ensure uniqueness.
MATCH (attempt), (client), (asn), (user), (asset)
WHERE id(attempt) = idFrom('attempt', $that.eventId, $that.timestamp)
AND id(client) = idFrom('client', $that.user.id, $that.client.ipAddress)
AND id(asn) = idFrom('asn', $that.client.asn)
AND id(user) = idFrom('user', $that.user.id)
AND id(asset) = idFrom('asset', $that.transaction.entityId)
A metric is set for the number of times an event occurs within the client, user, and asset nodes. This counter is used later to calculate the attempt success/fail ratio for specific assets.
//////////////////////////////
// Bucketing for counters
//////////////////////////////
CALL incrementCounter(client, "clientCount", 1) YIELD count AS clientCount
CALL incrementCounter(client, toLower($that.outcome.result), 1) YIELD count AS clientOutcomeCount
CALL incrementCounter(user, "userCount", 1) YIELD count AS userCount
CALL incrementCounter(user, toLower($that.outcome.result), 1) YIELD count AS userOutcomeCount
CALL incrementCounter(asset, "assetCount", 1) YIELD count AS assetCount
CALL incrementCounter(asset, toLower($that.outcome.result), 1) YIELD count AS assetOutcomeCount
Each node is then filled with parameters derived from the event itself.
//////////////////////////////
// Client
//////////////////////////////
SET client.device = $that.client.device,
client.ipAddress = $that.client.ipAddress,
client.userAgent = $that.client.userAgent,
client: client
// Identify last time client seen across clients //
SET client.lastseen = coll.max([$that.timestamp, coalesce(client.lastseen, $that.timestamp)])
// Percentage of success vs. failure //
SET client.successPercent = ceil(coalesce((client.success*1.0)/(client.count*1.0)*100.0, 0.0))
SET client.failurePercent = floor(coalesce((client.failure*1.0)/(client.count*1.0)*100.0, 0.0))
SET client.state = CASE
// Set threshold ratios below for each of three cases //
WHEN client.successPercent >= 90 THEN 'good'
WHEN client.successPercent >= 75 AND client.successPercent < 90 THEN 'warn'
WHEN client.successPercent < 75 THEN 'alarm'
ELSE 'alarm'
END
//////////////////////////////
// User
//////////////////////////////
SET user.id = $that.user.id,
user.alternateId = $that.user.alternateId,
user.displayName = $that.user.displayName,
user.type = $that.user.type,
user: user
// Identify last time user seen across users //
SET user.lastseen = coll.max([$that.timestamp, coalesce(user.lastseen, $that.timestamp)])
// Percentage of success vs. failure //
SET user.successPercent = ceil(coalesce((user.success*1.0)/(user.count*1.0)*100.0, 0.0))
SET user.failurePercent = floor(coalesce((user.failure*1.0)/(user.count*1.0)*100.0, 0.0))
SET user.state = CASE
// Set threshold ratios below for each of three cases //
WHEN user.successPercent >= 90 THEN 'good'
WHEN user.successPercent >= 75 AND user.successPercent < 90 THEN 'warn'
WHEN user.successPercent < 75 THEN 'alarm'
ELSE 'alarm'
END
//////////////////////////////
// Attempts
//////////////////////////////
SET attempt.schemaVersion = $that.schemaVersion,
attempt.eventId = $that.eventId,
attempt.transactionId = $that.transaction.id,
attempt.timestamp = $that.timestamp,
attempt.entityId = $that.transaction.entityId,
attempt.eventType = $that.eventType,
attempt.transactionType = $that.transaction.type,
attempt.eventCode = $that.eventCode,
attempt.displayMessage = $that.displayMessage,
attempt.outcomeResult = $that.outcome.result,
attempt.logLevel = $that.level,
attempt.zone = $that.client.zone,
attempt.client = $that.client.ipAddress,
attempt.userSequence = coalesce(userCount,0),
attempt.clientSequence = coalesce(clientCount,0),
attempt: attempt
//////////////////////////////
// ASN
//////////////////////////////
SET asn.id = $that.client.asn,
asn: asn
//////////////////////////////
// Asset
//////////////////////////////
SET asset.id = $that.transaction.entityId,
asset.detail = $that.client.requestUri,
asset: asset
// Percentage of success vs. failure //
SET asset.successPercent = ceil(coalesce((asset.success*1.0)/(asset.count*1.0)*100.0, 0.0))
SET asset.failurePercent = floor(coalesce((asset.failure*1.0)/(asset.count*1.0)*100.0, 0.0))
SET asset.state = CASE
// Set threshold ratios below for each of three cases //
WHEN asset.successPercent >= 90 THEN 'good'
WHEN asset.successPercent >= 75 AND asset.successPercent < 90 THEN 'warn'
WHEN asset.successPercent < 75 THEN 'alarm'
ELSE 'alarm'
END
Finally, relationships are created for all of the nodes generated from the event.
//////////////////////////////
// Create relationship between nodes
//////////////////////////////
CREATE (user)-[:ORIGINATED]->(attempt)-[:USING]->(client),
(client)<-[:USING]-(attempt)-[:TARGETED]->(asset),
(user)-[:ORIGINATED]->(attempt)-[:TARGETED]->(asset),
(attempt)-[:OVER]->(asn)
The complete INGEST-1 ingest stream configuration processes the endpoints.json
file:
- type: FileIngest
path: attempts.json
format:
type: CypherJson
query: >-
MATCH (attempt), (client), (asn), (user), (asset)
WHERE id(attempt) = idFrom('attempt', $that.eventId, $that.timestamp)
AND id(client) = idFrom('client', $that.user.id, $that.client.ipAddress)
AND id(asn) = idFrom('asn', $that.client.asn)
AND id(user) = idFrom('user', $that.user.id)
AND id(asset) = idFrom('asset', $that.transaction.entityId)
CALL incrementCounter(client, "clientCount", 1) YIELD count AS clientCount
CALL incrementCounter(client, toLower($that.outcome.result), 1) YIELD count AS clientOutcomeCount
CALL incrementCounter(user, "userCount", 1) YIELD count AS userCount
CALL incrementCounter(user, toLower($that.outcome.result), 1) YIELD count AS userOutcomeCount
CALL incrementCounter(asset, "assetCount", 1) YIELD count AS assetCount
CALL incrementCounter(asset, toLower($that.outcome.result), 1) YIELD count AS assetOutcomeCount
SET client.device = $that.client.device,
client.ipAddress = $that.client.ipAddress,
client.userAgent = $that.client.userAgent,
client: client
SET client.lastseen = coll.max([$that.timestamp, coalesce(client.lastseen, $that.timestamp)])
SET client.successPercent = ceil(coalesce((client.success*1.0)/(client.count*1.0)*100.0, 0.0))
SET client.failurePercent = floor(coalesce((client.failure*1.0)/(client.count*1.0)*100.0, 0.0))
SET client.state = CASE
WHEN client.successPercent >= 90 THEN 'good'
WHEN client.successPercent >= 75 AND client.successPercent < 90 THEN 'warn'
WHEN client.successPercent < 75 THEN 'alarm'
ELSE 'alarm'
END
SET user.id = $that.user.id,
user.alternateId = $that.user.alternateId,
user.displayName = $that.user.displayName,
user.type = $that.user.type,
user: user
SET user.lastseen = coll.max([$that.timestamp, coalesce(user.lastseen, $that.timestamp)])
SET user.successPercent = ceil(coalesce((user.success*1.0)/(user.count*1.0)*100.0, 0.0))
SET user.failurePercent = floor(coalesce((user.failure*1.0)/(user.count*1.0)*100.0, 0.0))
SET user.state = CASE
WHEN user.successPercent >= 90 THEN 'good'
WHEN user.successPercent >= 75 AND user.successPercent < 90 THEN 'warn'
WHEN user.successPercent < 75 THEN 'alarm'
ELSE 'alarm'
END
SET attempt.schemaVersion = $that.schemaVersion,
attempt.eventId = $that.eventId,
attempt.transactionId = $that.transaction.id,
attempt.timestamp = $that.timestamp,
attempt.entityId = $that.transaction.entityId,
attempt.eventType = $that.eventType,
attempt.transactionType = $that.transaction.type,
attempt.eventCode = $that.eventCode,
attempt.displayMessage = $that.displayMessage,
attempt.outcomeResult = $that.outcome.result,
attempt.logLevel = $that.level,
attempt.zone = $that.client.zone,
attempt.client = $that.client.ipAddress,
attempt.userSequence = coalesce(userCount,0),
attempt.clientSequence = coalesce(clientCount,0),
attempt: attempt
SET asn.id = $that.client.asn,
asn: asn
SET asset.id = $that.transaction.entityId,
asset.detail = $that.client.requestUri,
asset: asset
SET asset.successPercent = ceil(coalesce((asset.success*1.0)/(asset.count*1.0)*100.0, 0.0))
SET asset.failurePercent = floor(coalesce((asset.failure*1.0)/(asset.count*1.0)*100.0, 0.0))
SET asset.state = CASE
WHEN asset.successPercent >= 90 THEN 'good'
WHEN asset.successPercent >= 75 AND asset.successPercent < 90 THEN 'warn'
WHEN asset.successPercent < 75 THEN 'alarm'
ELSE 'alarm'
END
CREATE (user)-[:ORIGINATED]->(attempt)-[:USING]->(client),
(client)<-[:USING]-(attempt)-[:TARGETED]->(asset),
(user)-[:ORIGINATED]->(attempt)-[:TARGETED]->(asset),
(attempt)-[:OVER]->(asn)
{
"type": "FileIngest",
"path": "attempts.json",
"format": {
"type": "CypherJson",
"query": "MATCH (attempt), (client), (asn), (user), (asset) WHERE id(attempt) = idFrom('attempt', $that.eventId, $that.timestamp)\n AND id(client) = idFrom('client', $that.user.id, $that.client.ipAddress)\n AND id(asn) = idFrom('asn', $that.client.asn)\n AND id(user) = idFrom('user', $that.user.id)\n AND id(asset) = idFrom('asset', $that.transaction.entityId)\n\nCALL incrementCounter(client, \"clientCount\", 1) YIELD count AS clientCount CALL incrementCounter(client, toLower($that.outcome.result), 1) YIELD count AS clientOutcomeCount CALL incrementCounter(user, \"userCount\", 1) YIELD count AS userCount CALL incrementCounter(user, toLower($that.outcome.result), 1) YIELD count AS userOutcomeCount CALL incrementCounter(asset, \"assetCount\", 1) YIELD count AS assetCount CALL incrementCounter(asset, toLower($that.outcome.result), 1) YIELD count AS assetOutcomeCount\nSET client.device = $that.client.device,\n client.ipAddress = $that.client.ipAddress,\n client.userAgent = $that.client.userAgent,\n client: client\nSET client.lastseen = coll.max([$that.timestamp, coalesce(client.lastseen, $that.timestamp)]) SET client.successPercent = ceil(coalesce((client.success*1.0)/(client.count*1.0)*100.0, 0.0)) SET client.failurePercent = floor(coalesce((client.failure*1.0)/(client.count*1.0)*100.0, 0.0)) SET client.state = CASE\n WHEN client.successPercent >= 90 THEN 'good'\n WHEN client.successPercent >= 75 AND client.successPercent < 90 THEN 'warn'\n WHEN client.successPercent < 75 THEN 'alarm'\n ELSE 'alarm'\n END\nSET user.id = $that.user.id,\n user.alternateId = $that.user.alternateId,\n user.displayName = $that.user.displayName,\n user.type = $that.user.type,\n user: user\nSET user.lastseen = coll.max([$that.timestamp, coalesce(user.lastseen, $that.timestamp)]) SET user.successPercent = ceil(coalesce((user.success*1.0)/(user.count*1.0)*100.0, 0.0)) SET user.failurePercent = floor(coalesce((user.failure*1.0)/(user.count*1.0)*100.0, 0.0)) SET user.state = CASE\n WHEN user.successPercent >= 90 THEN 'good'\n WHEN user.successPercent >= 75 AND user.successPercent < 90 THEN 'warn'\n WHEN user.successPercent < 75 THEN 'alarm'\n ELSE 'alarm'\n END\nSET attempt.schemaVersion = $that.schemaVersion,\n attempt.eventId = $that.eventId,\n attempt.transactionId = $that.transaction.id,\n attempt.timestamp = $that.timestamp,\n attempt.entityId = $that.transaction.entityId,\n attempt.eventType = $that.eventType,\n attempt.transactionType = $that.transaction.type,\n attempt.eventCode = $that.eventCode,\n attempt.displayMessage = $that.displayMessage,\n attempt.outcomeResult = $that.outcome.result,\n attempt.logLevel = $that.level,\n attempt.zone = $that.client.zone,\n attempt.client = $that.client.ipAddress,\n attempt.userSequence = coalesce(userCount,0),\n attempt.clientSequence = coalesce(clientCount,0),\n attempt: attempt\nSET asn.id = $that.client.asn,\n asn: asn\nSET asset.id = $that.transaction.entityId,\n asset.detail = $that.client.requestUri,\n asset: asset\nSET asset.successPercent = ceil(coalesce((asset.success*1.0)/(asset.count*1.0)*100.0, 0.0)) SET asset.failurePercent = floor(coalesce((asset.failure*1.0)/(asset.count*1.0)*100.0, 0.0)) SET asset.state = CASE\n WHEN asset.successPercent >= 90 THEN 'good'\n WHEN asset.successPercent >= 75 AND asset.successPercent < 90 THEN 'warn'\n WHEN asset.successPercent < 75 THEN 'alarm'\n ELSE 'alarm'\n END\n\nCREATE (user)-[:ORIGINATED]->(attempt)-[:USING]->(client),\n (client)<-[:USING]-(attempt)-[:TARGETED]->(asset),\n (user)-[:ORIGINATED]->(attempt)-[:TARGETED]->(asset),\n (attempt)-[:OVER]->(asn)"
}
}
A standing query detects when new nodes enter the graph and creates NEXT
relationships making it easier to follow event sequences during analysis.
- pattern:
type: Cypher
parallelism: 32
query: |-
MATCH (client2)<-[:USING]-(attempt1)<-[:ORIGINATED]-(user)-[:ORIGINATED]->(attempt2)-[:USING]->(client1)
RETURN DISTINCT id(attempt2) AS attempt2
mode: DistinctId
outputs:
sequence:
type: CypherQuery
query: |-
MATCH (client2)<-[:USING]-(attempt2)<-[:ORIGINATED]-(user)-[:ORIGINATED]->(attempt1 {clientSequence: (attempt2.clientSequence-1)})-[:USING]->(client1)
WHERE id(attempt2) = $that.data.attempt2
AND id(client1) = id(client2)
CREATE (attempt2)<-[:NEXT]-(attempt1)
shouldRetry: false
{
"pattern": null,
"type": "Cypher",
"parallelism": 32,
"query": "MATCH (client2)<-[:USING]-(attempt1)<-[:ORIGINATED]-(user)-[:ORIGINATED]->(attempt2)-[:USING]->(client1)\nRETURN DISTINCT id(attempt2) AS attempt2",
"mode": "DistinctId",
"outputs": {
"sequence": {
"type": "CypherQuery",
"query": "MATCH (client2)<-[:USING]-(attempt2)<-[:ORIGINATED]-(user)-[:ORIGINATED]->(attempt1 {clientSequence: (attempt2.clientSequence-1)})-[:USING]->(client1)\nWHERE id(attempt2) = $that.data.attempt2\n AND id(client1) = id(client2)\nCREATE (attempt2)<-[:NEXT]-(attempt1)",
"shouldRetry": false
}
}
}
A second standing query matches 4 consecutive failed attempts followed by a successful attempt and outputs a URL that can be copied and pasted into a browser to open the subgraph in Quine for exploration.
- pattern:
type: Cypher
query: |-
MATCH (attempt1 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt2 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt3 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt4 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt5 {outcomeResult:"SUCCESS"})-[:USING]->(client4)
RETURN DISTINCT id(attempt1) AS attempt1
mode: DistinctId
outputs:
alert:
type: CypherQuery
query: |-
MATCH (attempt1 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt2 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt3 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt4 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt5 {outcomeResult:"SUCCESS"})
WHERE id(attempt1)=$that.data.attempt1
RETURN 'Password Spraying Attack: ' + 'http://localhost:8080/#' + text.urlencode('MATCH (user)-[:ORIGINATED]->(attempt1 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt2 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt3 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt4 {outcomeResult:"FAILURE"})-[:NEXT]->(attempt5 {outcomeResult:"SUCCESS"})-[:USING]->(client) WHERE id(attempt1)="' + toString(strId(attempt1)) + '" RETURN DISTINCT user,attempt1,attempt2,attempt3,attempt4,attempt5,client') AS QuineUILink
andThen:
type: PrintToStandardOut
{
"pattern": {
"type": "Cypher",
"query": "MATCH (attempt1 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt2 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt3 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt4 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt5 {outcomeResult:\"SUCCESS\"})-[:USING]->(client4)\nRETURN DISTINCT id(attempt1) AS attempt1",
"mode": "DistinctId"
},
"outputs": {
"alert": {
"type": "CypherQuery",
"query": "MATCH (attempt1 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt2 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt3 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt4 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt5 {outcomeResult:\"SUCCESS\"})\nWHERE id(attempt1)=$that.data.attempt1\nRETURN 'Password Spraying Attack: ' + 'http://localhost:8080/#' + text.urlencode('MATCH (user)-[:ORIGINATED]->(attempt1 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt2 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt3 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt4 {outcomeResult:\"FAILURE\"})-[:NEXT]->(attempt5 {outcomeResult:\"SUCCESS\"})-[:USING]->(client) WHERE id(attempt1)=\"' + toString(strId(attempt1)) + '\" RETURN DISTINCT user,attempt1,attempt2,attempt3,attempt4,attempt5,client') AS QuineUILink",
"andThen": {
"type": "PrintToStandardOut"
}
}
}
}
Running the Recipe¶
❯ java -jar quine-1.8.2.jar -r password_spraying.yml
Graph is ready
Running Recipe: Password Spraying Detection
Using 36 node appearances
Using 18 quick queries
Using 12 sample queries
Running Standing Query STANDING-1
Running Standing Query STANDING-2
Running Ingest Stream INGEST-1
Quine web server available at http://localhost:8080
Quine will process the events looking for the subgraph pattern that we defined in STANDING-2. When it encounters the pattern, it will emit a link to the event in the Exploration UI.
2023-02-10 15:34:54,460 Standing query `alert` match: {"meta":{"isPositiveMatch":true,"resultId":"7242b979-03c2-2bc3-9879-13661e8359b5"},"data":{"QuineUILink":"Password Spraying Attack: http://localhost:8080/#MATCH%20%28user%29-%5B%3AORIGINATED%5D-%3E%28attempt1%20%7BoutcomeResult%3A%22FAILURE%22%7D%29-%5B%3ANEXT%5D-%3E%28attempt2%20%7BoutcomeResult%3A%22FAILURE%22%7D%29-%5B%3ANEXT%5D-%3E%28attempt3%20%7BoutcomeResult%3A%22FAILURE%22%7D%29-%5B%3ANEXT%5D-%3E%28attempt4%20%7BoutcomeResult%3A%22FAILURE%22%7D%29-%5B%3ANEXT%5D-%3E%28attempt5%20%7BoutcomeResult%3A%22SUCCESS%22%7D%29-%5B%3AUSING%5D-%3E%28client%29%20WHERE%20id%28attempt1%29%3D%22cb73fb14-4686-3913-8cd8-7d4d608b53d5%22%20RETURN%20DISTINCT%20user%2Cattempt1%2Cattempt2%2Cattempt3%2Cattempt4%2Cattempt5%2Cclient"}}
Summary¶
Take time to explore the graph in the Quine Exploration UI. Start by right clicking on the contractor node and selecting the Attempts Timeline
quick query to generate the attack timeline.
The recipe contains a number of additional quick queries to view events.
Quick Query | Returns |
---|---|
Adjacent Nodes | Nodes |
Refresh | Node |
Local Properties | Text |
Admins that Targeted Asset | Nodes |
All User Types that Targeted Asset | Nodes |
Attempts Timeline | Nodes |
Contractors that Failed Authentication for Asset | Nodes |
Contractors that Targeted Asset | Nodes |
Failed Password Authentication Attempts | Nodes |
Guests that Targeted Asset | Nodes |
Next Attempt | Nodes |
Previous Attempt | Nodes |
Show Client and ASN | Nodes |
Targeted Assets | Nodes |
Targeted Assets | Nodes |
Users that Targeted Asset | Nodes |
Authentication attempts in chronological order | Text |
Authentication attempts in chronological order | Text |