#!/usr/bin/env bash
# check-screenconnect-extensions.sh: strictly read-only, offline review for the ConnectWise ScreenConnect partner-account
# campaign of November 2026 (no CVE, no patch). It reads a folder or an export you hand it. No network, no file writes,
# nothing is run, and it never prints keys (the k= and v= client parameters are not printed).
#
# Usage (one or more of --extensions-dir, --clients, --files-root):
#   --extensions-dir DIR  the ScreenConnect server's App_Extensions folder (or a copy). Lists each extension's Manifest.xml
#                         Name and flags the five 3rd party extensions ConnectWise disabled as a precaution
#   --clients FILE        ScreenConnect client service command lines, one per line (service name and ImagePath, e.g. from
#                         Win32_Service Name and PathName). Flags the rogue client IDs, IOC hosts and Exploited tags from the
#                         BleepingComputer report, and hosts not in --expected-host
#   --expected-host LIST  comma separated relay host names that are yours (OUR CHECK); clients pointing elsewhere are flagged
#   --files-root DIR      looks for the file names in the BleepingComputer report under DIR (12 levels, symlinks not followed)
#   --name NAME           label for the output (default: input)
# Verdicts: FINDING lines (NAMED EXTENSION, ROGUE CLIENT, IOC HOST, EXPLOITED TAG, NAMED FILE), REVIEW lines (POSSIBLE
#   EXTENSION, UNEXPECTED HOST, LOOSE FILE), UNKNOWN, NOTE, NO FINDINGS.
# Exit: 0 NO FINDINGS, 1 FINDINGS, 5 REVIEW (no findings), 3 UNKNOWN (nothing worse), 4 usage or read error.
set -uf
export LC_ALL=C

usage() {
  cat >&2 <<'U'
Usage: check-screenconnect-extensions.sh [--extensions-dir DIR] [--clients FILE] [--expected-host LIST] [--files-root DIR] [--name NAME]
  --extensions-dir  ScreenConnect App_Extensions folder (or a copy)
  --clients         ScreenConnect client service command lines, one per line
  --expected-host   comma separated relay host names that are yours (OUR CHECK)
  --files-root      folder to search for the file names in the BleepingComputer report
  --name            label for the output (default: input)
  --local           (Check-ScreenConnectExtensions.ps1 on Windows only) read this machine's ScreenConnect client services
At least one of --extensions-dir, --clients, --files-root is needed. Read-only: nothing is changed, keys are never printed.
Exit: 0 NO FINDINGS, 1 FINDINGS, 5 REVIEW, 3 UNKNOWN, 4 usage or read error
U
  exit 4
}
die() { echo "ERROR: $1" >&2; exit 4; }
say() { printf '%s: %s: %s\n' "$1" "$2" "$3"; }

# clean: the same reduction everywhere. UTF-8 continuation bytes are dropped, anything else outside the safe set becomes ?,
# and the value is cut to 120 characters.
CLEAN_AWK='function clean(v) { return substr(cleanall(v), 1, 120) }
  function cleanall(v,   o, i, c) {
    o = ""
    for (i = 1; i <= length(v); i++) {
      c = substr(v, i, 1)
      if (c ~ /[A-Za-z0-9 ._:@\/+=,()?%~#-]/) o = o c
      else if (c >= "\200" && c <= "\277") continue
      else o = o "?"
    }
    return o
  }'
clean() { printf '%s' "$1" | awk -v RS='\001' "$CLEAN_AWK"' { printf "%s", clean($0) }'; }
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
defang() { printf '%s' "${1//./[.]}"; }

NAMED="labtech plus|rmm plus|rmm+|rmm plus password link|rmm+ password link"
IOCS="185.99.255.28 155.94.150.39 newnewnew.gotdns.org soxnewwork.homedns.org cdn007-digitaldownloads.com sc.payproofkit.com ns.mo7sen.dev"
ROGUE="2588ef5c15954b10 ed630cc2c873b091"

# manifest_fields FILE: prints the first <Name> and <Author> of Manifest.xml as name|author, entities decoded, cleaned.
manifest_fields() {
  awk -v RS='\001' "$CLEAN_AWK"'
  function dec(s,   o, m, cp) {
    o = ""
    while (match(s, /&(amp|lt|gt|quot|apos|#[0-9]+|#x[0-9A-Fa-f]+);/)) {
      m = substr(s, RSTART + 1, RLENGTH - 2); o = o substr(s, 1, RSTART - 1)
      if (m == "amp") o = o "&"; else if (m == "lt") o = o "<"; else if (m == "gt") o = o ">"; else if (m == "quot") o = o "\""; else if (m == "apos") o = o "'"'"'"
      else {
        if (substr(m, 2, 1) == "x" || substr(m, 2, 1) == "X") cp = hex(substr(m, 3)); else cp = substr(m, 2) + 0
        if (cp >= 32 && cp < 127) o = o sprintf("%c", cp); else o = o "?"
      }
      s = substr(s, RSTART + RLENGTH)
    }
    return o s
  }
  function hex(h,   i, r) { r = 0; h = tolower(h); for (i = 1; i <= length(h) && i <= 6; i++) r = r * 16 + index("0123456789abcdef", substr(h, i, 1)) - 1; return r }
  function tag(s, t,   a, b, v) {
    a = index(s, "<" t ">"); if (a == 0) return "\001"
    v = substr(s, a + length(t) + 2); b = index(v, "</" t ">"); if (b == 0) return "\001"
    v = substr(v, 1, b - 1); gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", v); gsub(/[ \t\r\n]+/, " ", v)
    return clean(dec(v))
  }
  { s = $0; if (substr(s, 1, 3) == "\357\273\277") s = substr(s, 4); n = tag(s, "Name"); a = tag(s, "Author"); print n "|" a }' "$1"
}

is_guid() { [[ $1 =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; }

do_extensions() { # dir name
  local d=$1 n=$2 e b f nm au low tot=0 hit=0 pos=0 unk=0 loose=0 list
  if ! { [ -d "$d" ] && [ -r "$d" ] && [ -x "$d" ]; }; then die "cannot read extensions folder: $d"; fi
  list=$(find "$d" -mindepth 1 -maxdepth 1 -print 2>/dev/null | sort) || die "cannot read extensions folder: $d"
  while IFS= read -r e; do
    [ -n "$e" ] || continue
    b=$(clean "${e##*/}")
    if [ -f "$e" ] || [ -L "$e" ]; then
      loose=$((loose+1)); say REVIEW "$n (App_Extensions/$b)" "LOOSE FILE: a file at the root of App_Extensions, outside any extension folder. Installed extensions sit in their own GUID folders, so review what put it there (OUR HEURISTIC)."
      continue
    fi
    [ -d "$e" ] || continue
    if ! is_guid "${e##*/}"; then say NOTE "$n (App_Extensions/$b)" "folder name is not a GUID, so it is not read as an extension (OUR HEURISTIC)."; continue; fi
    tot=$((tot+1))
    f="$e/Manifest.xml"
    if [ ! -f "$f" ] || [ ! -r "$f" ]; then unk=$((unk+1)); say UNKNOWN "$n (extension $b)" "no readable Manifest.xml, so the extension name is unknown."; continue; fi
    IFS='|' read -r nm au <<< "$(manifest_fields "$f")"
    if [ "$nm" = $'\001' ] || [ -z "$nm" ]; then unk=$((unk+1)); say UNKNOWN "$n (extension $b)" "Manifest.xml has no Name element (or is not UTF-8), so the extension name is unknown."; continue; fi
    [ "$au" = $'\001' ] && au=""
    low=$(lower "$nm")
    case "|$NAMED|" in
      *"|$low|"*) hit=$((hit+1)); say "NAMED EXTENSION" "$n (extension $b)" "Manifest name \"$nm\" is one of the five 3rd party extensions ConnectWise disabled as a precaution. ConnectWise: confirm it is disabled, preserve related installation records and activity logs, and do not re-enable or reinstall it. This tool does not read whether it is enabled."; continue;;
    esac
    case $low in
      *"labtech plus"*|*"rmm plus"*|*"rmm+"*) pos=$((pos+1)); say REVIEW "$n (extension $b)" "POSSIBLE EXTENSION: Manifest name \"$nm\" resembles a name ConnectWise lists (Labtech Plus, RMM Plus, RMM+, RMM Plus Password Link, RMM+ Password Link) but is not an exact match. Compare it on the Extensions page (OUR HEURISTIC)."; continue;;
    esac
    say NOTE "$n (extension $b)" "installed extension \"$nm\"${au:+ by \"$au\"}."
  done <<< "$list"
  say NOTE "$n (App_Extensions)" "$tot extension folders read: $hit named by ConnectWise, $pos possible, $unk unreadable, $loose loose files."
  if [ "$hit" -eq 0 ] && [ "$pos" -eq 0 ] && [ "$unk" -eq 0 ] && [ "$loose" -eq 0 ]; then say "NO FINDINGS" "$n (App_Extensions)" "none of the five 3rd party extensions ConnectWise lists is installed in this folder."; fi
}

# client_fields FILE: one line per client line: id|h|p|c|hasquery|lowered-line (cleaned, not cut; used for IOC matching only)
client_fields() {
  awk -v RS='\n' "$CLEAN_AWK"'
  BEGIN { hx = "[0-9a-f]"; re = "screenconnect client \\(" hx hx hx hx hx hx hx hx hx hx hx hx hx hx hx hx "\\)" }
  {
    v = $0; if (NR == 1) sub(/^\357\273\277/, "", v)
    sub(/\r$/, "", v); sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v)
    if (v == "" || substr(v, 1, 1) == "#") next
    lv = tolower(v); id = ""
    if (match(lv, re)) id = substr(lv, RSTART + 22, 16)
    h = "\001"; p = "\001"; c = "\001"; hq = 0
    q = index(v, "?")
    if (q) {
      hq = 1; qs = substr(v, q + 1); e = index(qs, "\""); if (e) qs = substr(qs, 1, e - 1)
      np = split(qs, P, "&")
      for (i = 1; i <= np; i++) {
        k = P[i]; j = index(k, "="); if (j == 0) continue
        key = tolower(substr(k, 1, j - 1)); val = substr(k, j + 1)
        if (key == "h" && h == "\001") h = clean(val)
        else if (key == "p" && p == "\001") p = clean(val)
        else if (key == "c" && c == "\001") c = clean(val)
      }
    }
    print id "|" h "|" p "|" c "|" hq "|" cleanall(lv)
  }' "$1"
}

do_clients() { # file name
  local f=$1 n=$2 recs id h p c hq lv who tot=0 fnd=0 rev=0 unk=0 x lh hit
  if ! { [ -f "$f" ] && [ -r "$f" ]; }; then die "cannot read clients file: $f"; fi
  recs=$(client_fields "$f") || die "cannot read clients file: $f"
  while IFS='|' read -r id h p c hq lv; do
    [ -n "$lv" ] || continue
    tot=$((tot+1))
    who="$n (client ${id:-?})"
    for x in $ROGUE; do
      if [ "$id" = "$x" ]; then fnd=$((fnd+1)); say "ROGUE CLIENT" "$who" "client ID $x is one of the rogue ScreenConnect client IDs in the BleepingComputer report. Investigate with separate tooling, not an interactive ScreenConnect session (ConnectWise)."; fi
    done
    hit=""
    for x in $IOCS; do case $lv in *"$x"*) hit="${hit:+$hit, }$(defang "$x")";; esac; done
    if [ -n "$hit" ]; then fnd=$((fnd+1)); say "IOC HOST" "$who" "the service line references $hit, listed as attacker infrastructure in the BleepingComputer report."; fi
    if [ "$c" != $'\001' ]; then case $(lower "$c") in exploited*) fnd=$((fnd+1)); say "EXPLOITED TAG" "$who" "c=$c matches the installer pattern &c=Exploited<N> in the BleepingComputer report.";; esac; fi
    if [ "$hq" = 0 ] || [ "$h" = $'\001' ]; then unk=$((unk+1)); say UNKNOWN "$who" "no h= relay host in this line, so the instance it points at is unknown."; continue; fi
    [ "$p" = $'\001' ] && p="?"
    lh=$(lower "$h")
    if [ -n "$EXPECTED" ]; then
      case ",$EXPECTED," in
        *",$lh,"*) say NOTE "$who" "points at h=$h p=$p, which is in --expected-host (k= and other keys not printed).";;
        *) rev=$((rev+1)); say REVIEW "$who" "UNEXPECTED HOST: points at h=$h p=$p, which is not in --expected-host. A client pointing at an instance that is not yours gives that instance remote access (OUR CHECK; k= and other keys not printed).";;
      esac
    else say NOTE "$who" "points at h=$h p=$p (no --expected-host given, so not compared; k= and other keys not printed)."; fi
  done <<< "$recs"
  say NOTE "$n (clients file)" "$tot client lines read: $fnd findings, $rev pointing at hosts not in --expected-host, $unk without a host."
  if [ "$tot" -eq 0 ]; then say UNKNOWN "$n (clients file)" "no client lines in the export."
  elif [ "$fnd" -eq 0 ] && [ "$rev" -eq 0 ] && [ "$unk" -eq 0 ]; then say "NO FINDINGS" "$n (clients file)" "no rogue client ID, IOC host or Exploited tag from the BleepingComputer report${EXPECTED:+, and every client points at an expected host}."; fi
}

do_files() { # dir name
  local d=$1 n=$2 list f b rel cnt=0
  if ! { [ -d "$d" ] && [ -r "$d" ] && [ -x "$d" ]; }; then die "cannot read files root: $d"; fi
  list=$(find "$d" -maxdepth 12 -type f \( -iname 'panel-lateral.ps1' -o -iname 'runnew.cmd' -o -iname 'toolboxrelayuploader.exe' -o -iname 'run-lateral-full.bat' \) -print 2>/dev/null | sort)
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    cnt=$((cnt+1)); rel=${f#"$d"}; rel=${rel#/}; b=$(clean "$rel")
    say "NAMED FILE" "$n (file $b)" "the file name matches one in the BleepingComputer report. A name match alone proves nothing; preserve it and review it with your security tooling before removal (ConnectWise: preserve evidence before removing malicious software)."
  done <<< "$list"
  if [ "$cnt" -eq 0 ]; then say "NO FINDINGS" "$n (files under $(clean "$d"))" "none of Panel-Lateral.ps1, runnew.cmd, ToolboxRelayUploader.exe or Run-Lateral-Full.bat found (12 levels, symlinks not followed, unreadable folders skipped)."
  else say NOTE "$n (files under $(clean "$d"))" "$cnt files with names from the BleepingComputer report."; fi
}

ext="" cli="" froot="" EXPECTED="" name="input" seen=" "
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $1 in --extensions-dir|--clients|--expected-host|--files-root|--name) case $seen in *" $1 "*) usage;; esac; seen="$seen$1 ";; *) usage;; esac
  case $1 in --extensions-dir) ext=$2;; --clients) cli=$2;; --expected-host) EXPECTED=$2;; --files-root) froot=$2;; --name) name=$2;; esac
  shift 2
done
{ [ -n "$ext" ] || [ -n "$cli" ] || [ -n "$froot" ]; } || usage
if [ -n "$EXPECTED" ]; then
  [ -n "$cli" ] || die "--expected-host needs --clients"
  EXPECTED=$(lower "$EXPECTED")
  IFS=, read -r -a EH <<< "$EXPECTED"
  for x in "${EH[@]}"; do [[ $x =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || die "--expected-host takes host names or IPv4 addresses, comma separated: $(clean "$x")"; done
  case $EXPECTED in *,) die "--expected-host takes host names or IPv4 addresses, comma separated: ";; esac
fi

# Read everything before printing anything, so read errors leave stdout empty.
out=""
if [ -n "$ext" ]; then o=$(do_extensions "$ext" "$name") || exit 4; out+="$o"$'\n'; fi
if [ -n "$cli" ]; then o=$(do_clients "$cli" "$name") || exit 4; out+="$o"$'\n'; fi
if [ -n "$froot" ]; then o=$(do_files "$froot" "$name") || exit 4; out+="$o"$'\n'; fi
out=${out%$'\n'}
NF=$(printf '%s\n' "$out" | grep -cE '^(NAMED EXTENSION|ROGUE CLIENT|IOC HOST|EXPLOITED TAG|NAMED FILE): ')
NR_=$(printf '%s\n' "$out" | grep -cE '^REVIEW: ')
NU=$(printf '%s\n' "$out" | grep -cE '^UNKNOWN: ')

echo "check-screenconnect-extensions: offline, read-only review for the ScreenConnect partner-account campaign of November 2026 (no CVE, no patch). No network, no writes, keys never printed."
echo "ADVISORY (ConnectWise, published 11 Nov 2026 22:45 UTC, status: Cause and scope under investigation): the presence of these 3rd party extensions does not, by itself, establish impact."
echo "IOC SOURCE: rogue client IDs, hosts, the Exploited tag and file names come from the BleepingComputer report (11 Nov 2026 22:56 UTC) citing security researchers; ConnectWise has not published IOCs."
printf '%s\n' "$out"
[ "$NF" -gt 0 ] && echo "NOTE: $name: ConnectWise advises investigating suspected compromise with separate security tooling, not interactive ScreenConnect sessions, and preserving evidence before cleanup."
if [ "$NF" -gt 0 ]; then res=FINDINGS; rc=1
elif [ "$NR_" -gt 0 ]; then res=REVIEW; rc=5
elif [ "$NU" -gt 0 ]; then res=UNKNOWN; rc=3
else res="NO FINDINGS"; rc=0; fi
echo "RESULT: $res ($NF findings, $NR_ review, $NU unknown)"
exit "$rc"
