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.9.3.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 | 

