OtterCookie C2 by Its Three-Port Fingerprint

WindowsmacOSLinuxNetwork

IOC Hunt medium Contagious InterviewOtterCookie

The current OtterCookie variant splits its traffic across three ports on one host, 8085, 8086 and 8087, behind an Express server, which makes its command-and-control servers findable in internet scan data before any sample points at them.

What this hunt looks for

@unpacker on X observed that the OtterCookie variant used in Contagious Interview gives each job its own port: 8086 for primary file exfiltration, 8085 for secondary exfiltration, and 8087 for the Socket.IO command channel, clipboard capture and log upload. A server answering on all three with Express is an unusual shape, and the published examples cluster on one hosting provider.

The Censys query finds hosts presenting all three ports with Express. Compare the results with egress, proxy and EDR network logs: a developer workstation talking to one of them on any of the three ports is the lead. The indicator list carries the hosts the researcher's query returned and those a second researcher added from telemetry.

Technique
Command and control over an application-layer protocol (MITRE ATT&CK T1071.001) and exfiltration over the C2 channel (T1041) by the OtterCookie malware in Contagious Interview intrusions.
Data sources
Internet scan data (Censys), Firewall and proxy logs, EDR network telemetry
Last updated
2026-10-04

Indicators

The atomic values this hunt matches on, listed separately from the queries so they can be lifted into tooling that speaks none of the query languages below. Every value here is reported by one of the sources cited at the foot of this page.

TypeValueNote
IP 144.172.117.220 Returned by the three-port query in @unpacker's post of 11 August 2026.
IP 153.75.89.4 Returned by the three-port query in @unpacker's post of 11 August 2026.
IP 167.88.167.54 Returned by the three-port query in @unpacker's post of 11 August 2026.
IP 104.194.134.213 Returned by the three-port query in @unpacker's post of 11 August 2026.
IP 153.75.87.26 Returned by the three-port query in @unpacker's post of 11 August 2026.
IP 153.75.94.94 Returned by the three-port query in @unpacker's post of 11 August 2026.
IP 172.86.126.227 Returned by the three-port query in @unpacker's post of 11 August 2026.
IP 172.86.126.76 A further match from @BushidoToken's telemetry, posted in reply on 12 August 2026.
IP 172.86.107.141 A further match from @BushidoToken's telemetry, posted in reply on 12 August 2026.
IP 104.194.153.144 A further match from @BushidoToken's telemetry, posted in reply on 12 August 2026.
IP 45.61.148.220 A further match from @BushidoToken's telemetry, posted in reply on 12 August 2026.

Logic

Every query below implements these steps, so the same hunt returns the same result in each product.

  1. Collect: network connections from endpoints, firewalls and flow logs on Windows, macOS and Linux; Censys scan data for the fingerprint
  2. Match: one source reaches 2 or more of the ports 8085, 8086 and 8087 on a single public address
  3. Match: or a connection to one of the eleven listed fingerprint hosts on one of those ports (matches, not confirmed infections)
  4. Match: or, in Censys, a host runs Express on all of 8085, 8086 and 8087, which lists new candidates before any traffic
  5. Report: sources with the address, ports reached, first and last time, newest first

Queries

Defender / Sentinel KQL

Written for this site, from indicators reported by @unpacker on X, @BushidoToken on X

Reports each device and public address where two or more of the three ports were reached, a behaviour that survives a change of host.

// OtterCookie three-port fingerprint: one device reaching two or more of the ports 8085, 8086 and 8087 on a single public address
DeviceNetworkEvents
| where Timestamp > ago(90d)
| where RemotePort in (8085, 8086, 8087)
| where not(ipv4_is_private(RemoteIP))
| summarize Ports = make_set(RemotePort), PortCount = dcount(RemotePort), Processes = make_set(InitiatingProcessFileName, 5), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by DeviceName, RemoteIP
| where PortCount >= 2
     // optional: fingerprint matches from two researchers, they are not confirmed infections
     or RemoteIP in ("144.172.117.220", "153.75.89.4", "167.88.167.54", "104.194.134.213", "153.75.87.26", "153.75.94.94", "172.86.126.227", "172.86.126.76", "172.86.107.141", "104.194.153.144", "45.61.148.220")
| sort by LastSeen desc
Lower-fidelity parts (1)
  • the eleven listed fingerprint hosts — they are fingerprint matches from two researchers, not confirmed infections, and hosting changes. Remove: delete the `// optional: fingerprint matches` line and the `or RemoteIP in (...)` line below it.

Splunk SPL

Written for this site, from indicators reported by @unpacker on X, @BushidoToken on X

Counts distinct ports per source and public destination in firewall, flow or EDR network events.

```index=network is a placeholder for firewall, flow or EDR network events carrying src_ip, dest_ip and dest_port```
index=network earliest=-90d dest_port IN (8085, 8086, 8087)
| where NOT (cidrmatch("10.0.0.0/8", dest_ip) OR cidrmatch("172.16.0.0/12", dest_ip) OR cidrmatch("192.168.0.0/16", dest_ip) OR cidrmatch("127.0.0.0/8", dest_ip))
| stats dc(dest_port) AS ports values(dest_port) AS port_list min(_time) AS first_seen max(_time) AS last_seen BY src_ip, dest_ip
| where ports >= 2
    ```optional: fingerprint matches from two researchers, they are not confirmed infections```
    OR in(dest_ip, "144.172.117.220", "153.75.89.4", "167.88.167.54", "104.194.134.213", "153.75.87.26", "153.75.94.94", "172.86.126.227", "172.86.126.76", "172.86.107.141", "104.194.153.144", "45.61.148.220")
| convert ctime(first_seen) ctime(last_seen)
| sort - last_seen
Lower-fidelity parts (1)
  • the eleven listed fingerprint hosts — they are fingerprint matches from two researchers, not confirmed infections, and hosting changes. Remove: delete the ```optional: fingerprint matches``` comment and the `OR in(dest_ip, ...)` line below it.

Sigma

Written for this site, from indicators reported by @unpacker on X, @BushidoToken on X

A single-event rule on the listed hosts and ports, because the two-or-more-ports count is an aggregation that Sigma cannot express for Kusto.

title: Connection to an OtterCookie Three-Port Fingerprint Host
id: 4f8b2d6a-1c93-5e07-9a52-8d3e6b1f4c70
status: experimental
description: >
  A connection to one of the eleven hosts reported for the OtterCookie three-port
  fingerprint on port 8085, 8086 or 8087. The behavioural count of two or more of
  the ports on one address is applied in the KQL, SPL, ES|QL, XQL and LogScale
  queries, because a Sigma correlation cannot be converted for Kusto.
references:
  - https://x.com/unpacker/status/2087145269020561711
  - https://x.com/BushidoToken/status/2087454988414980358
logsource:
  category: network_connection
detection:
  # optional: fingerprint matches from two researchers, not confirmed infections
  selection_hosts:
    DestinationIp:
      - '144.172.117.220'
      - '153.75.89.4'
      - '167.88.167.54'
      - '104.194.134.213'
      - '153.75.87.26'
      - '153.75.94.94'
      - '172.86.126.227'
      - '172.86.126.76'
      - '172.86.107.141'
      - '104.194.153.144'
      - '45.61.148.220'
  selection_ports:
    DestinationPort:
      - 8085
      - 8086
      - 8087
  condition: selection_hosts and selection_ports
falsepositives:
  - A legitimate multi-service deployment on a recycled address
level: medium
Lower-fidelity parts (1)
  • the eleven listed fingerprint hosts — they are fingerprint matches from two researchers, not confirmed infections, and hosting changes. Remove: delete the whole rule, since the address list is its only content and the behavioural count is in the other queries.

CrowdStrike LogScale

Written for this site, from indicators reported by @unpacker on X, @BushidoToken on X

Counts distinct ports per host and public address from Falcon network events.

#event_simpleName=NetworkConnectIP4 in(RemotePort, values=[8085, 8086, 8087]) RemoteAddressIP4!=/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|127\.)/
| groupBy([ComputerName, aid, RemoteAddressIP4], function=[count(RemotePort, distinct=true, as=ports), collect([RemotePort]), min(@timestamp, as=first_seen), max(@timestamp, as=last_seen)])
| ports >= 2
    // optional: fingerprint matches from two researchers, they are not confirmed infections
    or in(RemoteAddressIP4, values=["144.172.117.220", "153.75.89.4", "167.88.167.54", "104.194.134.213", "153.75.87.26", "153.75.94.94", "172.86.126.227", "172.86.126.76", "172.86.107.141", "104.194.153.144", "45.61.148.220"])
| sort(last_seen, order=desc)
Lower-fidelity parts (1)
  • the eleven listed fingerprint hosts — they are fingerprint matches from two researchers, not confirmed infections, and hosting changes. Remove: delete the `// optional: fingerprint matches` line and the `or in(RemoteAddressIP4 ...)` line below it.

Elastic

Written for this site, from indicators reported by @unpacker on X, @BushidoToken on X

Counts distinct ports per host and public address, with the listed hosts flagged in a separate column.

FROM logs-*
| WHERE @timestamp > NOW() - 90 days AND destination.port IN (8085, 8086, 8087) AND NOT CIDR_MATCH(destination.ip, "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8")
| EVAL f_listed = TO_STRING(destination.ip) IN ("144.172.117.220", "153.75.89.4", "167.88.167.54", "104.194.134.213", "153.75.87.26", "153.75.94.94", "172.86.126.227", "172.86.126.76", "172.86.107.141", "104.194.153.144", "45.61.148.220")
| STATS ports = COUNT_DISTINCT(destination.port), port_list = VALUES(destination.port), listed = MAX(CASE(f_listed, 1, 0)), first_seen = MIN(@timestamp), last_seen = MAX(@timestamp) BY host.name, source.ip, destination.ip
| WHERE ports >= 2 OR listed == 1
| SORT last_seen DESC
| LIMIT 100
Lower-fidelity parts (1)
  • the eleven listed fingerprint hosts — they are fingerprint matches from two researchers, not confirmed infections, and hosting changes. Remove: delete the `| EVAL f_listed` line, ` listed = MAX(CASE(f_listed, 1, 0)),` from the `| STATS` line and ` OR listed == 1` from the `| WHERE` line.

Cortex XQL

Written for this site, from indicators reported by @unpacker on X, @BushidoToken on X

Counts distinct ports per host and public address from XDR network events.

dataset = xdr_data
| filter event_type = ENUM.NETWORK and action_remote_port in (8085, 8086, 8087) and not (action_remote_ip ~= "^(10[.]|192[.]168[.]|172[.](1[6-9]|2[0-9]|3[01])[.]|127[.])")
| comp count_distinct(action_remote_port) as ports, min(_time) as first_seen, max(_time) as last_seen by agent_hostname, action_remote_ip
| filter ports >= 2
    // optional: fingerprint matches from two researchers, they are not confirmed infections
    or action_remote_ip in ("144.172.117.220", "153.75.89.4", "167.88.167.54", "104.194.134.213", "153.75.87.26", "153.75.94.94", "172.86.126.227", "172.86.126.76", "172.86.107.141", "104.194.153.144", "45.61.148.220")
| sort desc last_seen
Lower-fidelity parts (1)
  • the eleven listed fingerprint hosts — they are fingerprint matches from two researchers, not confirmed infections, and hosting changes. Remove: delete the `// optional: fingerprint matches` line and the `or action_remote_ip in (...)` line below it.

osquery

Written for this site, from indicators reported by @unpacker on X, @BushidoToken on X

A point-in-time check of open sockets on one host for two or more of the three ports to one public address, since osquery keeps no history.

SELECT s.remote_address, count(DISTINCT s.remote_port) AS ports, group_concat(DISTINCT s.remote_port) AS port_list, group_concat(DISTINCT p.name) AS processes
FROM process_open_sockets s
JOIN processes p USING (pid)
WHERE s.remote_port IN (8085, 8086, 8087)
  AND s.remote_address NOT LIKE '10.%' AND s.remote_address NOT LIKE '192.168.%' AND s.remote_address NOT LIKE '127.%'
  AND regex_match(s.remote_address, '^172\.(1[6-9]|2[0-9]|3[01])\.', 0) IS NULL
GROUP BY s.remote_address
HAVING ports >= 2
   -- optional: fingerprint matches from two researchers, they are not confirmed infections
   OR s.remote_address IN ('144.172.117.220', '153.75.89.4', '167.88.167.54', '104.194.134.213', '153.75.87.26', '153.75.94.94', '172.86.126.227', '172.86.126.76', '172.86.107.141', '104.194.153.144', '45.61.148.220')
Lower-fidelity parts (1)
  • the eleven listed fingerprint hosts — they are fingerprint matches from two researchers, not confirmed infections, and hosting changes. Remove: delete the `-- optional: fingerprint matches` line and the `OR s.remote_address IN (...)` line below it.

Shodan / Censys

Written for this site, from indicators reported by @unpacker on X

Ports are integers, the Express clause is bound to each port within one service, both vendor and product fields are accepted because Censys may record Express under either, and the post's RouterHosting LLC pin is left out to catch the same build elsewhere.

host.services: (port = 8085 and (software.vendor = "express" or software.product = "express"))
and host.services: (port = 8086 and (software.vendor = "express" or software.product = "express"))
and host.services: (port = 8087 and (software.vendor = "express" or software.product = "express"))

False positives and tuning

Express on high ports is common in development and staging, and three adjacent ports open together will occasionally be a legitimate multi-service deployment.

A match is a host worth checking against your own logs, not a finding on its own.

The indicator list is a set of fingerprint matches rather than servers seen in a confirmed infection.

Sources

Tuning required before use This hunt is a starting point. Adapt the queries to your schema, validate them against your baseline and tune them for the false positives noted above. Indicators go stale as operators rotate hosts.