Detecting ManualFinder/PDF Editor Malware Campaign with KQL
- 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.

-----------------------------------------------------------------------------------------------------------------
Attack Flow
Initial Installation: Users install what appears to be legitimate PDF editing software (Under the names PDF Editor, AppSuite-PDF)
Triggering the Switch: A scheduled JS file fires up, installing ManualFinder via MSI.
The Sleeper Awakens: The application receives an update via --cm--fullupdate parameter
Execute Order 66: The "update" transforms the benign application into an infostealer
Reconnaissance Phase: The malware begins systematic enumeration
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.
-----------------------------------------------------------------------------------------------------------------
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

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.