Today I opened a remote SSH workspace on a server with VSCode. As an engineer, few things trigger your immediate response faster than running git status and finding a random, cryptic file I did not create right in the root directory of a projet.

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    ''$'\001''4'$'\253\006''@W@8'

What t** h*** is this?

ls -li
total 20
130323 -rw-r--r--  1 nicolasbages nicolasbages    0 Jun 21 10:15 ''$'\001''4'$'\253\006''@W@8'
[...]

The file with the inode 130323 has a very strange name and is 0 octet. Empty, ok, maybe just a file created by a script. I deleted it.

find . -inum 130323 -delete

But then after reopening this project, it was back! The cybersecurity landscape being so noisy lately following supply chain attacks on NPM packages and VSCode extensions, it couldn't be left unchecked.

Phase 1: Catching the ghost

The filesystems ext4 does not store the Creator PID inside a file's metadata, we cannot read who made it.

To catch it, we can use the Linux Audit Framework auditd on the remote host. By setting a filesystem watch rule on the workspace directory.

apt install auditd

auditctl -w /home/nicolasbages/playbook/ -p wa -k file_creation_tracker

After triggering the file creation by reloading VS Code, we query the audit logs using ausearch:

ausearch -k file_creation_tracker

The footprints left by the process

The kernel returns the following block:

time->Sun Jun 21 10:30:42 2026
type=PROCTITLE msg=audit(1782030642.033:23): proctitle=2F62696E2F7368002F686F6D652F6E69636F6C617362616765732F2E7673636F64652D7365727665722F657874656E73696F6E732F6769746C61622E6769746C61622D776F726B666C6F772D362E38332E322F6173736574732F6C616E67756167652D7365727665722F7267002D2D76657273696F6E
type=PATH msg=audit(1782030642.033:23): item=1 name=0134AB0640574038 inode=130323 dev=08:02 mode=0100644 ouid=1000 ogid=1000 rdev=00:00 nametype=CREATE
type=CWD msg=audit(1782030642.033:23): cwd="/home/nicolasbages/playbook"
type=SYSCALL msg=audit(1782030642.033:23): arch=c00000b7 syscall=56 success=yes exit=3 ppid=415372 pid=415409 comm="sh" exe="/usr/bin/dash" key="file_creation_tracker"

At first glance, we see /usr/bin/dash but we need to check for arguments because dash alone, while being the "culprit", doesn't help much.

The proctitle field contains the raw hex of the command. Let's convert that string (2F62696E2F736800...) back into human-readable ASCII text:

/bin/sh /home/nicolasbages/.vscode-server/extensions/gitlab.gitlab-workflow-6.83.2/assets/language-server/rg --version

Alright, it executes rg. RipGrep is a tool bundled in IDEs and extensions to parse code syntax. We will see later that it is buddled with the extension.

The SYSCALL line tells us:

  • syscall=56: This maps to the openat system call.
  • a2=241: Translated from hex 0x241 into POSIX file system it means O_CREAT | O_WRONLY | O_TRUNC.

So, the process requested the OS to create a new WO file inside its current working directory cwd="/home/nicolasbages/playbook".

At that moment in time I started to think it looked more like a bug than an attack.

  • 0 byte footprint.
  • file visible in the root directory of the project.
  • rg is known.

But, why would a simple version check rg --version create an empty file inside my project workspace?

Phase 2: Looking for the upstream bug

Time to investigate the gitlab.gitlab-workflow-6.83.2 extension.

cat /home/nicolasbages/.vscode-server/extensions/gitlab.gitlab-workflow-6.83.2/assets/language-server/main-bundle-node.js | grep "rg --version"
# nothing

cat /home/nicolasbages/.vscode-server/extensions/gitlab.gitlab-workflow-6.83.2/assets/language-server/main-bundle-node.js | grep "rg "
[...]
(`rg already extracted at ${s}`),s;(0,AD.mkdirSync)(n,{recursive:!0});let a=`${s}.tmp.${process.pid}`;return(0,AD.writeFileSync)(a,t),process.platform!=="win32"&&(0,AD.chmodSync)(a,493),(0,AD.renameSync)(a,s),this.#e.info(`rg extracted to ${s}`),s}static{Lgi()}});
[...]
# Ah !

Let's download Gitlab workflow language server from Gitlab's official repository and look at the original source code.

Turns out the piece of code found with grep in the minified code comes from packages/lib_workflow_executor/src/services/default_rg_binary_provider.ts added 2 months ago.

// packages/lib_workflow_executor/src/services/default_rg_binary_provider.ts

  async #extractBinary(): Promise<string> {
    // Use Node fs to read the embedded file (works in both Bun and Node runtimes)
    const binaryData = readFileSync(this.#embeddedPath);

    const hash = createHash("sha256")
      .update(binaryData)
      .digest("hex")
      .slice(0, 8);

    const binaryName = process.platform === "win32" ? "rg.exe" : "rg";
    const extractDir = join(tmpdir(), "gitlab-duo-cli", "bin", hash);
    const extractedPath = join(extractDir, binaryName);

    if (existsSync(extractedPath)) {
      this.#logger.info(`rg already extracted at ${extractedPath}`);
      return extractedPath;
    }

    mkdirSync(extractDir, { recursive: true });

    // Write to temp file first, then atomically rename to avoid corrupted binary
    // if process is killed mid-write
    const tmpPath = `${extractedPath}.tmp.${process.pid}`;
    writeFileSync(tmpPath, binaryData);

    if (process.platform !== "win32") {
      chmodSync(tmpPath, 0o755);
    }

    renameSync(tmpPath, extractedPath);

    this.#logger.info(`rg extracted to ${extractedPath}`);
    return extractedPath;
  }

This class shows:

  1. The code relies on Node's tmpdir() in line 50 to find a safe directory. Usually it should be /tmp but in the context of a remote SSH environment, the directory is the workspace.
  2. So it writes a temprary file in the workspace directory with writeFileSync(tmpPath).
  3. The kernel receives the system call syscall=56 with the O_CREAT flag. It creates the inode on disk, 0-byte, with the raw-byte name ''$'\001''4'$'\253\006''@W@8'.

However, during initialization, the extension multiple spawns background shell checks. If the extension or VSCode's remote extension host decides to restart the language server, it kills the process.

Because Node is killed mid-operation, it never reaches the renameSync() line 69 which would move and clean up the file. And because it's a hard process termination, Node's internal garbage collection and exception handlers never run.

Clean-up and mitigation

Because the filename contains raw characters, rm doesn't work.

find . -inum 130323 -delete

At the time of writing, there is no update available for this extension so I decided to disable it for now.