VDO.Ninja (OBS.Ninja) Screen Relay and Interview-Support Web Services

WindowsmacOSNetwork

IOC Hunt medium DPRK IT Worker FraudLaptop FarmFamous Chollima

Browser artifacts of the screen-relay and audio/video test services Huntress recovered from a suspected DPRK worker's laptop, including the vdo.ninja push URL used to stream the host's screen out.

What this hunt looks for

VDO.Ninja is a legitimate browser tool that carries a webcam, microphone or screen share over WebRTC into OBS or another production application. On the laptop Huntress examined it was doing something else: a push URL with the screenshare parameter set, recovered from the Chrome cache, streaming the host's own screen out to whoever held the matching view link.

Around it sat services that make sense only for someone rehearsing a call they will not attend: microphone and webcam test sites, an address checker consulted minutes before a Zoom meeting, peer-to-peer file transfer, and a public code-sharing site holding recurring Zoom invitations with embedded passwords. Huntress recommends hunting the whole cluster rather than any single service.

The service was called OBS.Ninja until a 2022 rebrand and that name still circulates in setup guides and bookmarks, so the queries below match obs.ninja alongside the current domain.

Technique
Screen capture and exfiltration over a legitimate web service (MITRE ATT&CK T1113, T1567), supporting impersonation during video interviews and meetings.
Data sources
Proxy logs, DNS 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
Domain vdo.ninja Browser-based WebRTC relay; Huntress assessed its use here as screen capture of the host.
Domain obs.ninja Former domain of the same service, which rebranded from OBS.Ninja to VDO.Ninja in 2022 as the project's own repository records. Huntress reports vdo.ninja; obs.ninja is carried here because a proxy or EDR rule written against one name does not match the other.
Domain mictests.com Microphone test site accessed minutes after the IP-KVM audio interface registered (Huntress).
Domain webcammictest.com Webcam and microphone test site (Huntress).
Domain onlinemictest.com Microphone test site (Huntress).
Domain ip.me Address-checking service accessed minutes before joining a Zoom meeting (Huntress).
Service Toffeeshare Peer-to-peer file transfer, used with a Chrome extension that records browser tab video and audio (Huntress). The domain is the service's own; Huntress names the service rather than a URL.
Service SendGB File-transfer service; a third party uploaded a file that the user then downloaded to C:\asset (Huntress).
Service Codeshare Public code-sharing site used to publish recurring Zoom meetings with embedded passwords (Huntress).

Logic

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

  1. Collect: DNS lookups and network connections on Windows and macOS endpoints, plus proxy URLs where they are ingested
  2. Match: destination host is VDO.Ninja or OBS.Ninja, a mic or webcam test site, ip.me, Toffeeshare, SendGB or Codeshare
  3. Report: devices reaching 2 or more distinct services in 90 days, or one VDO.Ninja URL with push= and screenshare

Queries

Defender / Sentinel KQL

Written for this site

Devices that reached two or more of the services, or sent a screenshare push URL, with the services seen and the newest hit first.

let Services = dynamic([
    // optional: widely used for ordinary business, the weakest entries of the cluster
    "ip.me", "toffeeshare.com", "sendgb.com", "codeshare.io",
    "vdo.ninja", "obs.ninja", "mictests.com", "webcammictest.com", "onlinemictest.com"
]);
DeviceNetworkEvents
| where Timestamp > ago(90d)
| where RemoteUrl has_any (Services)
| mv-apply Service = Services to typeof(string) on (where RemoteUrl has Service)
| summarize Services = make_set(Service), ServiceCount = dcount(Service),
            // optional: counts VDO.Ninja or OBS.Ninja URLs carrying push= and screenshare; needs telemetry that keeps the query string
            ScreenPush = countif(Service endswith ".ninja" and RemoteUrl has "push=" and RemoteUrl has "screenshare"),
            LastSeen = max(Timestamp)
        by DeviceName, InitiatingProcessAccountName
| where ServiceCount >= 2 or ScreenPush > 0
| sort by LastSeen desc
Lower-fidelity parts (2)
  • the ip.me, toffeeshare.com, sendgb.com and codeshare.io entries — an address checker and three file and code sharing services that ordinary staff use daily, so they add devices that only hold the weak half of the cluster. Remove: Delete the two lines beginning // optional: and the four names that follow on the next line.
  • the ScreenPush counter — high fidelity when it fires but silent wherever the telemetry drops the query string. Remove: Delete the line beginning // optional: counts and the ScreenPush line after it, then change the final where to | where ServiceCount >= 2.

Splunk SPL

Written for this site

Devices and users that reached two or more of the services in proxy or DNS data, or sent a screenshare push URL.

```sourcetype placeholders: index=proxy (url, src_ip, user) and index=dns or sysmon (query, dest, user); field names follow the CIM Web and Network_Resolution models```
(index=proxy OR index=dns OR index=sysmon) (url=* OR query=*) earliest=-90d
| eval target=lower(coalesce(url, query))
```optional: the last four names are widely used for ordinary business; delete them from the pattern to drop them```
| rex field=target "(?:^|[./@])(?<service>vdo\.ninja|obs\.ninja|mictests\.com|webcammictest\.com|onlinemictest\.com|ip\.me|toffeeshare\.com|sendgb\.com|codeshare\.io)(?:[:/?#]|$)"
| where isnotnull(service)
| eval device=coalesce(dest, src_ip)
```optional: counts VDO.Ninja or OBS.Ninja URLs carrying push= and screenshare; needs a source that keeps the query string```
| eval screen_push=if(like(service, "%.ninja") AND like(target, "%push=%") AND like(target, "%screenshare%"), 1, 0)
| stats dc(service) AS service_count values(service) AS services sum(screen_push) AS screen_push_urls max(_time) AS last_seen BY device, user
| where service_count>=2 OR screen_push_urls>0
| sort 0 - last_seen
| convert ctime(last_seen)
Lower-fidelity parts (2)
  • the ip.me, toffeeshare.com, sendgb.com and codeshare.io entries — an address checker and three file and code sharing services that ordinary staff use daily, so they add devices that only hold the weak half of the cluster. Remove: Delete |ip\.me|toffeeshare\.com|sendgb\.com|codeshare\.io from the rex pattern.
  • the screen_push counter — high fidelity when it fires but silent wherever the source drops the query string. Remove: Delete the optional comment, the eval screen_push line and sum(screen_push) AS screen_push_urls, then change the final where to | where service_count>=2.

Sigma

Written for this site

A single DNS lookup of one of the services, which is low severity alone and is meant to be counted per device by the SIEM.

title: Lookup of a Screen-Relay or Interview-Support Web Service
id: 5f3c1d52-8a4e-4c1b-9d0a-6b7e2f4a9c31
status: experimental
description: >
  Detects a DNS lookup of VDO.Ninja (formerly OBS.Ninja), the microphone and
  webcam test sites, the ip.me address checker or the Toffeeshare, SendGB and
  Codeshare services that Huntress recovered from the laptop of a suspected
  DPRK remote worker. One lookup is unremarkable; report devices that reach
  two or more of the services.
references:
  - https://www.huntress.com/blog/huntress-dprk-remote-worker-investigation
  - https://github.com/steveseguin/vdo.ninja
logsource:
  category: dns_query
detection:
  selection_exact:
    QueryName:
      - 'vdo.ninja'
      - 'obs.ninja'
      - 'mictests.com'
      - 'webcammictest.com'
      - 'onlinemictest.com'
      # optional: widely used for ordinary business, the weakest entries of the cluster
      - 'ip.me'
      - 'toffeeshare.com'
      - 'sendgb.com'
      - 'codeshare.io'
  selection_subdomain:
    QueryName|endswith:
      - '.vdo.ninja'
      - '.obs.ninja'
      - '.mictests.com'
      - '.webcammictest.com'
      - '.onlinemictest.com'
      # optional: widely used for ordinary business, the weakest entries of the cluster
      - '.ip.me'
      - '.toffeeshare.com'
      - '.sendgb.com'
      - '.codeshare.io'
  condition: selection_exact or selection_subdomain
falsepositives:
  - Media production, streaming and broadcast staff using VDO.Ninja for its intended purpose
level: low
Lower-fidelity parts (1)
  • the ip.me, toffeeshare.com, sendgb.com and codeshare.io entries — an address checker and three file and code sharing services that ordinary staff use daily, so they add devices that only hold the weak half of the cluster. Remove: Delete the four lines for ip.me, toffeeshare.com, sendgb.com and codeshare.io, and the two # optional: comment lines above them, from both lists.

Elastic

Written for this site

Hosts and users that reached two or more of the services, or sent a screenshare push URL, across DNS and URL fields.

FROM logs-*
| WHERE @timestamp > NOW() - 90 days
| EVAL target = TO_LOWER(COALESCE(url.original, dns.question.name, destination.domain))
| WHERE target IS NOT NULL
| GROK target "(?:^|[./@])(?<service>vdo\\.ninja|obs\\.ninja|mictests\\.com|webcammictest\\.com|onlinemictest\\.com|ip\\.me|toffeeshare\\.com|sendgb\\.com|codeshare\\.io)(?:[:/?#]|$)"
| WHERE service IS NOT NULL
| EVAL screen_push = CASE(service LIKE "*.ninja" AND target LIKE "*push=*" AND target LIKE "*screenshare*", 1, 0)
| STATS service_count = COUNT_DISTINCT(service), services = VALUES(service), screen_push_urls = SUM(screen_push), last_seen = MAX(@timestamp) BY host.name, user.name
| WHERE service_count >= 2 OR screen_push_urls > 0
| SORT last_seen DESC
| LIMIT 200
Lower-fidelity parts (2)
  • the ip.me, toffeeshare.com, sendgb.com and codeshare.io entries — an address checker and three file and code sharing services that ordinary staff use daily, so they add devices that only hold the weak half of the cluster. Remove: Delete |ip\\.me|toffeeshare\\.com|sendgb\\.com|codeshare\\.io from the GROK pattern.
  • the screen_push counter — high fidelity when it fires but silent wherever the source drops the query string. Remove: Delete the EVAL screen_push line and screen_push_urls = SUM(screen_push), then change the second-last WHERE to WHERE service_count >= 2.

CrowdStrike LogScale

Written for this site

Devices that looked up two or more of the services in Falcon DNS events.

// Falcon DNS lookups carry the host name only, so the screenshare URL check is not available here.
#event_simpleName=DnsRequest
// optional: the last four names are widely used for ordinary business; delete them from the pattern to drop them
| DomainName=/(?:^|\.)(?<service>vdo\.ninja|obs\.ninja|mictests\.com|webcammictest\.com|onlinemictest\.com|ip\.me|toffeeshare\.com|sendgb\.com|codeshare\.io)$/i
| lower(field=service, as=service)
| groupBy([aid, ComputerName], function=[count(service, distinct=true, as=service_count), collect(service), max(@timestamp, as=last_seen)])
| service_count >= 2
| sort(last_seen, order=desc)
Lower-fidelity parts (1)
  • the ip.me, toffeeshare.com, sendgb.com and codeshare.io entries — an address checker and three file and code sharing services that ordinary staff use daily, so they add devices that only hold the weak half of the cluster. Remove: Delete |ip\.me|toffeeshare\.com|sendgb\.com|codeshare\.io from the regular expression.

Cortex XQL

Written for this site

Hosts and users that looked up two or more of the services in Cortex network events.

// Cortex DNS lookups carry the host name only, so the screenshare URL check is not available here.
dataset = xdr_data
| filter event_type = ENUM.NETWORK and dns_query_name != null
// optional: the last four names are widely used for ordinary business; delete them from the pattern to drop them
| alter service = arrayindex(regextract(lowercase(dns_query_name), "(?:^|\.)(vdo\.ninja|obs\.ninja|mictests\.com|webcammictest\.com|onlinemictest\.com|ip\.me|toffeeshare\.com|sendgb\.com|codeshare\.io)$"), 0)
| filter service != null
| comp count_distinct(service) as service_count, values(service) as services, max(_time) as last_seen by agent_hostname, actor_effective_username
| filter service_count >= 2
| sort desc last_seen
Lower-fidelity parts (1)
  • the ip.me, toffeeshare.com, sendgb.com and codeshare.io entries — an address checker and three file and code sharing services that ordinary staff use daily, so they add devices that only hold the weak half of the cluster. Remove: Delete |ip\.me|toffeeshare\.com|sendgb\.com|codeshare\.io from the regextract pattern.

False positives and tuning

Every service here is legitimate and widely used: VDO.Ninja for broadcast work, test sites before an important call, file transfer for ordinary business traffic. None says anything individually.

The hunt rests on the co-occurrence Huntress documents, so it requires two or more distinct services; any one alone is noise.

Defender and most DNS sources record only the host portion of a URL, so the push and screenshare check works from proxy logs and rarely from EDR telemetry.

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.