deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

Stolen GitHub OAuth token used to forge commits with hidden loaders across 22 repos

A developer writes on dev.to that a stolen GitHub OAuth token was used to force-push forged commits carrying whitespace-hidden loaders to 22 repositories, triggered by dev servers, CI builds and simply opening a project in VS Code.

Stolen GitHub OAuth token used to forge commits with hidden loaders across 22 repos

Forged commits across 22 repositories

According to a first-person account on dev.to, a developer discovered that an attacker had force-pushed forged copies of their newest commits to 22 repositories and 42 branches, with four further branches later found carrying an older variant. The fake commits matched the originals in message, author name and date, and GitHub's security log attributed the pushes to the developer's own account.

The activity looked scripted: the post describes two waves of pushes, around 03:58 UTC on 2026-10-22 and around 08:00 UTC on 2026-11-05, each hitting roughly 30 repositories in about two minutes.

A failed build was the first clue

A Netlify deployment of a small invoicing app failed with an outdated-lockfile error, and package.json contained two dependencies the developer had never added: dotenv and node-fetch. The habitual fix, regenerating the lockfile and pushing again, would have installed those packages and run the injected code during the build. The broken build was the only thing preventing execution.

The decisive evidence sat in Git's raw commit data. Git records both an author and a committer on every commit, and GitHub's interface displays only the author. The attacker copied the author identity exactly, but committer timestamps on the forgeries carried -0800 or -0700 timezones while the developer, based in Lagos, commits with +0100. The commit SHAs on GitHub also failed to match local clones.

What the forged commits planted

Each forged commit added a loader wired into routine developer actions:

  • A file named init at the repo root and inside monorepo packages. Its first 30 lines create a temporary folder; the real payload is obfuscated JavaScript placed after roughly 13,000 spaces on the last line, invisible in editors and diffs. The earlier wave used a file named api.js with the same trick.
  • Rewritten package.json scripts, with commands like dev and build prefixed by node init && so every run executed the loader first.
  • The same whitespace-hidden code appended to build-time configs including postcss.config.mjs, astro.config.mjs, eslint.config.js and tailwind.config.js, each gaining a createRequire import header that reads like ordinary ESM boilerplate.
  • In one Vite app, an appended async block fetched a URL stored as base64 in a committed .env file and evaluated the downloaded text. Decoded, it pointed at info.anondrop[.]net, so the live payload never lived in the repo.
  • A .vscode/tasks.json shell task set to run on folderOpen, executing node against a file disguised as a font (.lfk), with output hidden and automatic tasks enabled. Opening the folder in VS Code or Cursor could trigger it, since Workspace Trust rarely stops people trusting their own repos.
  • A rewritten .gitignore that dropped the rules for .env* and .vscode/*, which is how those files got committed, and gained entries such as temp_auto_push.bat and branch_structure.json, apparent leftovers from the attacker's Windows push tooling. Many files were also converted to Windows line endings, burying real changes inside large diffs.

Where the code executed

According to the post, a GitHub Actions workflow built the forged commits successfully. Because the build command ran the loader first, a green check meant the code had executed in CI, in an environment holding a Convex deploy key the developer has since rotated. An old local worktree with node_modules already present could also have run the payload through a dev command.

The likely way in

The account held no personal access tokens and no SSH keys, leaving an OAuth token as the probable credential. Git Credential Manager, the GitHub sign-in inside editors such as VS Code and Cursor, and the GitHub CLI all store one on the machine, and a token lifted from an infected computer is enough to push as that user. The developer suspects a newly introduced Windows workstation, cannot prove it, and is wiping the machine regardless.

The point the author stresses is that changing the password would have changed nothing: OAuth tokens survive password changes, skip two-factor prompts, and must be revoked outright.

The remediation involved revoking every OAuth and GitHub App authorization except Netlify, Vercel and CloudCannon, signing out all sessions, restoring one repository from genuine local commits, and cleaning the other branches with removal commits layered on top rather than history rewrites, performed on Git objects directly so the infected code was never checked out or run. The forgeries remain in history, and a separate AI agent reviewed all 42 cleanup commits, including whether each lockfile still installed.

Why it matters

This compromise needed nothing exotic: one stolen token, copied commit metadata, and payloads hidden in whitespace and config files. The lessons generalize. Password rotation and 2FA do not revoke OAuth grants, so token hygiene is the actual control. Author metadata in GitHub's UI is trivially forgeable, making committer timezones and SHA comparisons against local clones practical checks. An unfamiliar dependency behind a lockfile error is a security signal, not a chore. And the delivery vectors, a dev command, a CI build, opening a folder in an editor, are actions developers perform daily without a second thought, which is precisely why the attacker chose them.

  • #github
  • #git
  • #security
  • #supply-chain
  • #malware

Related posts