Skip to content

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

Download Recipe

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:

Download attempts.json

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.

Locate empty nodes in the graph
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.

Create parameters for Client nodes
//////////////////////////////
// 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
Create parameters for User nodes
//////////////////////////////
// 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
Create parameters for Attempts nodes
//////////////////////////////
// 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
Create parameters for ASN nodes
//////////////////////////////
// ASN
//////////////////////////////
SET asn.id = $that.client.asn,
    asn: asn
Create parameters for Asset nodes
//////////////////////////////
// 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)
POST /api/v1/ingest/INGEST-1
{
  "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
/api/v1/query/standing/STANDING-1
{
  "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
/api/v1/query/standing/STANDING-1
{
  "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"}}

Event Pattern

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.

Event 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