top of page

Detecting ManualFinder/PDF Editor Malware Campaign with KQL

  • Writer: Damien van der Linden
    Damien van der Linden
  • 22 hours ago
  • 7 min read

The ManualFinder and PDF Editor malware campaign represents a chain attack that turns legitimate-looking applications (well, I guess..) into information stealers and more. In this post, we'll walk through building a comprehensive KQL hunting query that leverages external IOC sources for real-time threat detection.

-----------------------------------------------------------------------------------------------------------------


The Campaign Overview


Over the past week, Expel’s team dropped a blog post on this threat: They’re seeing PUPs (Potentially Unwanted Programs) up their game; dropping malicious files, making odd network calls, even turning your machine into a residential proxy. For a deep-dive, check their blog!


Their investigation found that ManualFinder gets installed via a JavaScript-based scheduled task, which then quietly runs msiexec /qn /i ManualFinder-v2.0.196.msi, meaning it installs in silent mode. This all starts with an advertisement of a free PDF editor, upon installation, it lifts some Javascript with it.

Reddit corroborated this, noting the .msi was dropped from a JS file, and that the signer “GLINT SOFTWARE SDN. BHD.” has had their signature revoked.

Other researchers (like Cyber Security News) picked up on this too, calling it a clever campaign to weaponize a PDF editor and turn devices into proxies.


In my own experience, I have seen that this PDF Editor triggers Infostealer-like behaviour on devices upon a new update. And created a full KQL hunting query to find this behaviour.

So yeah, didn't think I'd ever say this but; might want to stick to Adobe.



ManualFinder? Why? We have AI now. My ChatGPT knows everything about my microwave-oven combination.
ManualFinder? Why? We have AI now. My ChatGPT knows everything about my microwave-oven combination.


-----------------------------------------------------------------------------------------------------------------


Attack Flow


  1. Initial Installation: Users install what appears to be legitimate PDF editing software (Under the names PDF Editor, AppSuite-PDF)

  2. Triggering the Switch: A scheduled JS file fires up, installing ManualFinder via MSI.

  3. The Sleeper Awakens: The application receives an update via --cm--fullupdate parameter

  4. Execute Order 66: The "update" transforms the benign application into an infostealer

  5. Reconnaissance Phase: The malware begins systematic enumeration

  6. Interacting with Web Data: This is where it tries to grab your Web Data files, containing all usernames and passwords you saved in your browser.


    The 'very legitimate' application that starts it all.
    The 'very legitimate' application that starts it all.

-----------------------------------------------------------------------------------------------------------------


Building the Hunting Query

Dynamic IOC Sources from GitHub

Instead of jamming our statements with IOCs, let's make it dynamic by using GitHub. Much cleaner and easier to update! I have gathered these IOCs from my own hunts and online sources. If you have more IOCs, please let me know!

// External IOC Data Sources - Auto-updating from GitHub
let FileHashes = externaldata(Hash: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/hashes.txt"]
with (format="txt", ignoreFirstRecord=false);

let C2Domains = externaldata(Domain: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/c2.txt"]
with (format="txt", ignoreFirstRecord=false);

let PDFDomains = externaldata(Domain: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/domains.txt"]
with (format="txt", ignoreFirstRecord=false);

let SuspiciousSigners = externaldata(Signer: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/signers.txt"]
with (format="txt", ignoreFirstRecord=false);

let MaliciousCommands = externaldata(Command: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/commands.txt"]
with (format="txt", ignoreFirstRecord=false);

Why External Data?

  • Real-time updates without query modifications

  • Team collaboration through GitHub

  • Version control for IOC management

  • Reduced false positives through curated lists

Static Indicators


Some indicators remain consistent across all samples, it's all from AppSuite and in my experience, these indicators haven't budged so I added them:

// These signatures are consistent across the campaign
let SuspiciousFileDescriptions = dynamic([
    "PDF EDITOR BY APPSUITE"
]);

let SuspiciousCompanyNames = dynamic([
    "AppSuite"
]);

Data Optimization


Convert external data to arrays for efficient lookups while filtering noise:

let HashList = toscalar(FileHashes | where isnotempty(Hash) and Hash != "" | summarize make_list(Hash));
let C2DomainList = toscalar(C2Domains | where isnotempty(Domain) and Domain != "" | summarize make_list(Domain));
let PDFDomainList = toscalar(PDFDomains | where isnotempty(Domain) and Domain != "" | summarize make_list(Domain));
let SignerList = toscalar(SuspiciousSigners | where isnotempty(Signer) and Signer != "" | summarize make_list(Signer));
let CommandList = toscalar(MaliciousCommands | where isnotempty(Command) and Command != "" | summarize make_list(Command));

Understanding the Attack Pattern


Browser Enumeration

The malware begins by checking which browsers are running:

powershell.exe "Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' }"
powershell.exe "Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'msedge.exe' }"

Security Product Detection

Next, it enumerates installed security products that may ruin their plans:

reg query "HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\Bitdefender" /v "UninstallString"
reg query "HKLM\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\G DATA ANTIVIRUS" /v "UninstallString"
reg query "HKCU\Software\CheckPoint\ZANG"
reg query "HKCU\Software\KasperskyLabSetup"
reg query "HKLM\Software\Fortinet"

Browser Termination

Finally, it kills browser processes to access locked files:

taskkill /IM msedge.exe
taskkill /IM chrome.exe

This systematic approach allows the malware to steal browser data (credentials, cookies, etc.) while avoiding detection by security products.


This is the behaviour we observed, however, it seems to be capable of more than infostealers. Since it checks the registry sometimes for browsers like EpiBrowser (browser hijackers), I assume it will also hijack browsers completely where possible.



-----------------------------------------------------------------------------------------------------------------


Multi-Vector Detection Strategy

Our hunting query employs multiple detection vectors:

1. File Hash Matching

DeviceFileEvents
| where SHA256 in (HashList) or SHA1 in (HashList) or MD5 in (HashList)

2. Certificate-Based Detection

DeviceFileCertificateInfo
| where Signer in (SignerList)
| join kind=inner DeviceFileEvents on SHA1

3. Behavioral Pattern Detection

DeviceProcessEvents
| where ProcessCommandLine has_any (CommandList)

4. Network Communication Monitoring

DeviceNetworkEvents
| where RemoteUrl has_any (C2DomainList) or RemoteUrl has_any (PDFDomainList)

Cleaning up the data


I had a really nice query now, but its results were quite messy and in some tenants, I had LOADS of results. Mainly because this ad-campaign has been so massive that many, many devices have made connections to these domains at some point, without installing anything.

This isn't neccesarily malicious, so I had the idea to initialize an LLM to help me categorize and assign a risk score per device. Claude.ai has always been a lovely tool for creating risk assessments, so I used Claude to help me finalize the query.


Device-Centric Risk Assessment


The final piece aggregates findings by device for a cleaner triage, thanks Claude:

| summarize 
    FirstActivity = min(TimeGenerated),
    LastActivity = max(TimeGenerated),
    TotalAlerts = count(),
    IOCTypes = make_set(IOCType),
    UniqueUsers = make_set(InitiatingProcessAccountName),
    SampleCommands = make_set_if(ProcessCommandLine, isnotempty(ProcessCommandLine), 3),
    SampleFiles = make_set_if(FileName, isnotempty(FileName), 5),
    MaliciousDomains = make_set_if(RemoteUrl, isnotempty(RemoteUrl), 5)
    by DeviceName
| extend 
    RiskScore = case(
        array_length(IOCTypes) >= 4, "CRITICAL",
        array_length(IOCTypes) >= 3, "HIGH", 
        array_length(IOCTypes) >= 2, "MEDIUM",
        "LOW"
    )

Risk Scoring Logic

  • CRITICAL: 4+ IOC types detected (full compromise)

  • HIGH: 3+ IOC types (significant compromise)

  • MEDIUM: 2+ IOC types (partial compromise)

  • LOW: Single IOC type (initial detection)

Complete Query

Here's the full hunting query that brings it all together:

// External IOC Data Sources
let FileHashes = externaldata(Hash: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/hashes.txt"]
with (format="txt", ignoreFirstRecord=false);
let C2Domains = externaldata(Domain: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/c2.txt"]
with (format="txt", ignoreFirstRecord=false);
let PDFDomains = externaldata(Domain: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/domains.txt"]
with (format="txt", ignoreFirstRecord=false);
let SuspiciousSigners = externaldata(Signer: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/signers.txt"]
with (format="txt", ignoreFirstRecord=false);
let MaliciousCommands = externaldata(Command: string)
[@"https://raw.githubusercontent.com/LindenSec/IoC/refs/heads/main/PDFEditorManualFinder/commands.txt"]
with (format="txt", ignoreFirstRecord=false);
// Static IOCs that remain consistent across samples
let SuspiciousFileDescriptions = dynamic([
    "PDF EDITOR BY APPSUITE"
]);
let SuspiciousCompanyNames = dynamic([
    "AppSuite"
]);
// Convert external data to arrays for efficient lookups (filtered for non-empty values)
let HashList = toscalar(FileHashes | where isnotempty(Hash) and Hash != "" | summarize make_list(Hash));
let C2DomainList = toscalar(C2Domains | where isnotempty(Domain) and Domain != "" | summarize make_list(Domain));
let PDFDomainList = toscalar(PDFDomains | where isnotempty(Domain) and Domain != "" | summarize make_list(Domain));
let SignerList = toscalar(SuspiciousSigners | where isnotempty(Signer) and Signer != "" | summarize make_list(Signer));
let CommandList = toscalar(MaliciousCommands | where isnotempty(Command) and Command != "" | summarize make_list(Command));
// Hunt across multiple data sources
union (
    // Hunt in Device File Events for hash matches
    DeviceFileEvents
    | where TimeGenerated >= ago(30d)
    | where SHA256 in (HashList) or SHA1 in (HashList) or MD5 in (HashList)
    | extend IOCType = "File Hash Match"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, FileName, FolderPath, SHA256, SHA1, MD5, IOCType
),
(
    // Hunt in Device Process Events for hash matches
    DeviceProcessEvents  
    | where TimeGenerated >= ago(30d)
    | where SHA256 in (HashList) or SHA1 in (HashList) or MD5 in (HashList)
    | extend IOCType = "Process Hash Match"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, ProcessCommandLine, FileName, FolderPath, SHA256, SHA1, MD5, IOCType
),
(
    // Hunt for suspicious signers using DeviceFileCertificateInfo
    DeviceFileCertificateInfo
    | where TimeGenerated >= ago(30d)
    | where Signer in (SignerList)
    | join kind=inner (
        DeviceFileEvents
        | where TimeGenerated >= ago(30d)
    ) on SHA1
    | extend IOCType = "Suspicious Certificate Signer"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, FileName, FolderPath, Signer, SHA256, SHA1, IOCType
),
(
    // Hunt for suspicious company names in version info
    DeviceFileEvents
    | where TimeGenerated >= ago(30d)
    | where InitiatingProcessVersionInfoCompanyName in (SuspiciousCompanyNames)
    | extend IOCType = "Suspicious Company Name"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, FileName, FolderPath, InitiatingProcessVersionInfoCompanyName, SHA256, IOCType
),
(
    // Hunt for suspicious file descriptions in version info
    DeviceProcessEvents
    | where TimeGenerated >= ago(30d)
    | where InitiatingProcessVersionInfoFileDescription in (SuspiciousFileDescriptions)
    | extend IOCType = "Suspicious File Description"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, ProcessCommandLine, FileName, FolderPath, InitiatingProcessVersionInfoFileDescription, SHA256, IOCType
),
(
    // Hunt for malicious commands in process events using GitHub command list
    DeviceProcessEvents
    | where TimeGenerated >= ago(30d)
    | where ProcessCommandLine has_any (CommandList)
    | extend IOCType = "Malicious Command Execution"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, ProcessCommandLine, InitiatingProcessFileName, SHA256, IOCType
),
(
    // Hunt for network connections to malicious domains
    DeviceNetworkEvents
    | where TimeGenerated >= ago(30d) 
    | where RemoteUrl has_any (C2DomainList) or RemoteUrl has_any (PDFDomainList) or
            RemoteIP has_any (C2DomainList) or RemoteIP has_any (PDFDomainList)
    | extend IOCType = "Malicious Domain Connection"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, InitiatingProcessFileName, RemoteUrl, RemoteIP, RemotePort, IOCType
),
(
    // Hunt in DNS queries
    DeviceEvents
    | where TimeGenerated >= ago(30d)
    | where ActionType == "DnsQueryResponse"
    | extend DnsQuery = tostring(parse_json(AdditionalFields).DnsQueryName)
    | where DnsQuery has_any (C2DomainList) or DnsQuery has_any (PDFDomainList)
    | extend IOCType = "Malicious DNS Query"
    | project TimeGenerated, DeviceName, InitiatingProcessAccountName, DnsQuery, IOCType
)
| summarize 
    FirstActivity = min(TimeGenerated),
    LastActivity = max(TimeGenerated),
    TotalAlerts = count(),
    IOCTypes = make_set(IOCType),
    UniqueUsers = make_set(InitiatingProcessAccountName),
    SampleCommands = make_set_if(ProcessCommandLine, isnotempty(ProcessCommandLine), 3),
    SampleFiles = make_set_if(FileName, isnotempty(FileName), 5),
    MaliciousDomains = make_set_if(RemoteUrl, isnotempty(RemoteUrl), 5),
    DNSQueries = make_set_if(DnsQuery, isnotempty(DnsQuery), 5),
    FileHashes = make_set_if(SHA256, isnotempty(SHA256), 5)
    by DeviceName
| extend 
    RiskScore = case(
        array_length(IOCTypes) >= 4, "CRITICAL",
        array_length(IOCTypes) >= 3, "HIGH", 
        array_length(IOCTypes) >= 2, "MEDIUM",
        "LOW"
    ),
    ActivitySpan = LastActivity - FirstActivity
| project 
    DeviceName,
    RiskScore,
    TotalAlerts,
    FirstActivity,
    LastActivity,
    ActivitySpan,
    IOCTypes,
    UniqueUsers,
    SampleCommands,
    SampleFiles,
    MaliciousDomains,
    DNSQueries,
    FileHashes
| sort by 
    case(RiskScore == "CRITICAL", 1, RiskScore == "HIGH", 2, RiskScore == "MEDIUM", 3, 4) asc,
    TotalAlerts desc
Some results! Or well, quite a lot sadly.
Some results! Or well, quite a lot sadly.

TL;DR Key Benefits

  • Dynamic IOC pulls from GitHub → Always fresh

  • Multi-pronged detection → No single point of failure

  • Risk-based view → Helps triage fast

  • Lightens SOC workload → Stop drowning in alerts

  • Community Collaboration: Enables IOC sharing through version-controlled repositories

------------------------------------------------------------------------------------------------------------------

Conclusion


This campaign is a textbook example of how malware hides in trusted tools, legit-sounding provenance, and a slow-burn approach. But with dynamic hunting, multi-vector detection, and risk scoring, you’re not just passively waiting, you’re proactively hunting.

Remember: in the world of cybersecurity, even the most innocent-looking PDF editor might be waiting for its Order 66 moment. Stay vigilant, hunt proactively, and keep your IOCs current.


I hope this helps! This has been a very nasty campaign to clean up and has hit many devices across the globe. A lot is still unclear, I don’t reverse binaries for a living (yet), but I know how to hunt this stuff down in a SOC environment.

2025-2026 LindenSec | ©
bottom of page