7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Burpが生成する証明書のCommonNameはどこからやってくるのか

Posted at

Web Security の勉強をしていると、Burp Suite を使ってHTTPやHTTPS通信の内容を調べることが多いと思います。
Burp Suite は暗号化されたHTTPS通信についても、MITM(man in the middle)の仕組みを使ってBurp自身が通信先のHTTPSサーバであるかのように振る舞い、それにより暗号化を一度ほどいて、平文のHTTPリクエスト/レスポンスを記録 & 表示してくれます。
Burp Suite のMITMではアクセス先のサーバ毎に Burp 自身でサーバ証明書が都度生成されます。
今回は、「この時生成されるサーバ証明書の Common Name ってどこからやってくるのか?」という疑問 についてJavaのサーバ/クライアントコードを使って調べてみました。

調査環境:

結論

先に結論だけ書くと、HTTP Client からの CONNECT メソッドの request line のホスト名で common name と subject alt name を生成していました。

CONNECT host1.test:443 HTTP/1.1
Host: host2.test:443

という CONNECT メソッドで Burp Suite に接続すると、Burp は host1.test という common name + subject alt name でMITM用のサーバ証明書を生成します。

ホスト名相当がやり取りされるのは、他にもSNI: Server Name Indication や接続先Webサーバからのサーバ証明書に含まれる common name + subject alt name がありますが、そちらは使っていませんでした。

Proxyを介したHTTPS通信の仕組み

Proxyに対応した HTTP Client では、HTTPS通信を以下のように処理します。
(参考: 「HTTP(S) Proxyを設定する」とはどういうことか、パケットレベルで解説 - セキュアスカイプラス )

  1. Proxyに接続し、平文で RFC7230 : authority-form のHTTPリクエストを送信(CONNECT メソッド)
    CONNECT www.example.com:80 HTTP/1.1
    
  2. Proxy から HTTP/1.1 200 が返されたら、同じ socket 通信でTLSハンドシェイクを開始
  3. TLSハンドシェイクが成功したら、本来のHTTPリクエストを送信(TLSにより暗号化される)
    GET / HTTP/1.1
    Host: www.example.com
    

Proxy がMITM用のサーバ証明書を生成するのは上記の 2. と 3. の間のタイミングになります。
そこまでに「ホスト名」相当の情報がやり取りされる箇所を整理してみます。

外部からProxyにホスト名相当が渡される箇所を整理してみます。

(1) で CONNECT HTTPリクエストを送っています。ここで、一般的なクライアントであれば CONNECT に続く authority-form の <host>:<port>Host: リクエストヘッダーの <host>:<port> は一致する前提となります。しかし後述する検証コードではあえて異なるホスト名を埋め込み、Burpがどこを参照するか区別できるようにしてみます。

続いてProxyにホスト名送られるのは (3) の HTTP Client から Proxy への ClientHello メッセージです。正確には ClientHello メッセージの拡張仕様である RFC6066 Server Name Indication になります。
今回の検証コードでも、意図的に CONNECT メソッドで送るのとは異なるホスト名を送ってみます。

その後は (5) でWeb Server から Proxy に本来のサーバ証明書を返します。
サーバ証明書の中には Common Name (CN) と Subject Alternative Name (SAN) が含まれており、Proxyがこちらを参照している可能性もあります。そこで今回の検証では、CNとSANそれぞれ異なるホスト名を設定した自己署名証明書を作成し、それを返すWeb Serverを作ります。

検証作業

今回は自身が使い慣れているJavaで、HTTP Client / Web Server それぞれサンプルコードを作成してみました。
JavaでTLSの Server を作るときは「証明書ストア」ファイルを作る必要があり、これには JKS (Java Key Store) 形式やPKCS12形式を選べます。
今回は PKCS12 形式を採用し、openssl コマンドを使って自己署名証明書の生成と証明書ストアを準備します。

(1) 自己署名証明書の生成とPKCS12証明書ストアの準備

Git for Windows に同梱の openssl コマンドを使用。
検証時のバージョン:

$ openssl version
OpenSSL 3.2.4 11 Feb 2025 (Library: OpenSSL 3.2.4 11 Feb 2025)

ポイント:

  • openssl req -new -x509 用の設定ファイルとコマンドラインオプションを活用し、CN と SAN それぞれで異なるホスト名を埋め込む
  • SAN についても wildcard と個別ホスト名を混在させ、どれが採用されたか区別しやすくした

手順:

  1. RSA 2048bit の秘密鍵を生成
    $ openssl genpkey -algorithm RSA -out server.key -pkeyopt rsa_keygen_bits:2048
    
  2. この後の openssl req -new -x509 コマンドで使う、Subject Alternative Name (SAN) 用の設定ファイルを準備
    $ cat <<EOF > req-x509-with-san.conf
    [req]
    default_bits       = 2048
    prompt             = no
    default_md         = sha256
    distinguished_name = my_dn
    req_extensions     = my_req_ext
    
    [my_dn]
    CN = dn-cn.test
    
    [my_req_ext]
    subjectAltName = @my_alt_names
    
    [my_alt_names]
    DNS.1 = *.test
    DNS.2 = san-1.test
    DNS.3 = san-2.test
    EOF
    
  3. 自己署名証明書の作成と CN, SAN 内容の確認
    $ openssl req -new -x509 -key server.key -out server.crt -days 3650 -config req-x509-with-san.conf -extensions my_req_ext
    
    $ openssl x509 -in server.crt -text -noout
    Certificate:
        Data:
            Version: 3 (0x2)
    (...)
            Issuer: CN=dn-cn.test
    (...)
            X509v3 extensions:
                X509v3 Subject Alternative Name:
                    DNS:*.test, DNS:san-1.test, DNS:san-2.test
    
  4. PKCS12形式の証明書ストアファイルの作成(パスワードは空文字列)
    $ openssl pkcs12 -export -out server.p12 -inkey server.key -in server.crt -name "ConfusingCertificate"
    Enter Export Password:(Enter)
    
    Verifying - Enter Export Password:(Enter)
    

(2) Web Server 用Javaサンプルコード

(1) で生成した server.p12 と同じフォルダに、Javaサンプルコードを準備します。

ConfusingCertificateWebServer.java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyStore;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.SSLSocket;

public class ConfusingCertificateWebServer {

    public static void main(final String... args) throws Exception {

        if (args.length < 1) {
            System.out.println("Usage: <port>");
            return;
        }
        final int port = Integer.parseInt(args[0]);

        // load demo keystore(pkcs12)
        final InputStream pkcs12 = Files.newInputStream(Paths.get("server.p12"));
        final KeyStore ks = KeyStore.getInstance("PKCS12");
        ks.load(pkcs12, "".toCharArray());

        final KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509");
        kmf.init(ks, "".toCharArray());

        final SSLContext sc = SSLContext.getInstance("TLS");
        sc.init(kmf.getKeyManagers(), null, null);

        final SSLServerSocketFactory ssf = sc.getServerSocketFactory();
        try (SSLServerSocket serverSocket = (SSLServerSocket) ssf.createServerSocket(port)) {
            System.out.println("ConfusingCertificateWebServer started on port " + port);
            System.out.println("Ctrl-C stops the server.");

            while (true) {
                final SSLSocket clientSocket = (SSLSocket) serverSocket.accept();
                new Thread(() -> ConfusingCertificateWebServer.handleClient(clientSocket)).start();
            }
        }
    }

    private static void handleClient(SSLSocket socket) {
        try (BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) {

            String line;
            while ((line = in.readLine()) != null && !line.isEmpty()) {
                // demo : don't use http request, simply ignored.
            }
            out.write("HTTP/1.1 200 OK\r\n");
            out.write("Content-Type: text/plain\r\n");
            out.write("\r\n");
            out.write("Hello from ConfusingCertificateWebServer!\r\n");
            out.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                socket.close();
            } catch (IOException ignored) {
            }
        }
    }
}

ポイント: Web Server 自体のコード上は、特筆すべき点はありません。素直にPKCS12形式で証明書ストアを読み込み、典型的な multi-threaded のデモ用のシンプルなWebサーバを実装しています。

コンパイルと実行:

$ javac ConfusingCertificateWebServer.java

$ java ConfusingCertificateWebServer 8081
ConfusingCertificateWebServer started on port 8081
Ctrl-C stops the server.

実際にWebブラウザ上でアクセスしてみて、サーバ証明書の内容を確認します。
Chromeでは net::ERR_CERT_AUTHORITY_INVALID エラーが最初に表示されますが、「詳細設定」→「localhost にアクセスする(安全ではありません)」をクリックして強制的にアクセスさせます。

アドレスバーに「保護されていない通信」と表示されるので、そこからサーバ証明書の内容を確認してみます。

Common Name:
2025-08-19,burp-mitm-server-cert-origin-01.png

Subject Alternative Name:
2025-08-19,burp-mitm-server-cert-origin-02.png

req-x509-with-san.conf で設定したとおり、全てバラバラのホスト名の証明書となっていることを確認できました。

(3) HTTP Client 用Javaサンプルコード

ソースコード中のコメントでポイントを解説します。

ConfusingCertificateProxiedClient.java
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;

import javax.net.ssl.HandshakeCompletedEvent;
import javax.net.ssl.HandshakeCompletedListener;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;

public class ConfusingCertificateProxiedClient {

    public static void main(final String... args) throws Exception {

        if (args.length < 3) {
            System.out.println("Usage: <proxy-host> <proxy-port> <ConfusingCertificateWebServer-listening-port>");
            return;
        }
        final String proxyHost = args[0];
        final int proxyPort = Integer.parseInt(args[1]);
        final String destinationHost = "localhost";
        final int destinationPort = Integer.parseInt(args[2]);

        try (final Socket proxySocket = new Socket(proxyHost, proxyPort)) {
            PrintWriter out = new PrintWriter(proxySocket.getOutputStream(), true);
            BufferedReader in = new BufferedReader(new InputStreamReader(proxySocket.getInputStream()));

            // ポイント1: 意図的に CONNECT の authority-form 中の host と
            // Host: リクエストヘッダーの host を異なる値にしています。
            out.printf("CONNECT %s:%d HTTP/1.1\r\nHost: host-header-connect.test:%d\r\n\r\n",
                destinationHost,
                destinationPort,
                destinationPort);
            out.flush();

            // receive proxy response
            String line;
            boolean connected = false;
            while ((line = in.readLine()) != null) {
                if (line.startsWith("HTTP/1.1 200")) {
                    connected = true;
                }
                if (line.isEmpty())
                    break; // end of http request headers
            }
            if (!connected) {
                System.err.println("Proxy CONNECT failed.");
                return;
            }

            // ポイント2: 今回は検証用として、サーバ証明書を検証せずになんでも受け入れるようにしています。
            // (Burpが生成したMITM用のサーバ証明書しか返されない想定)
            final TrustManager[] trustAllCertificateManagers = new TrustManager[] {
                new X509TrustManager() {
                    @Override
                    public X509Certificate[] getAcceptedIssuers() {
                        return new X509Certificate[0];
                    }

                    @Override
                    public void checkClientTrusted(X509Certificate[] certs, String authType) {
                    }

                    @Override
                    public void checkServerTrusted(X509Certificate[] certs, String authType) {
                    }
                }
            };

            final SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, trustAllCertificateManagers, new SecureRandom());

            // upgrade to TLS
            final SSLSocketFactory factory = sslContext.getSocketFactory();
            final SSLSocket sslSocket = (SSLSocket) factory.createSocket(proxySocket, proxyHost, proxyPort, true);

            // ポイント3: 全く別のホスト名をSNIで送信しています。
            final SSLParameters sslParameters = sslSocket.getSSLParameters();
            sslParameters.setServerNames(List.of(new SNIHostName("client-sni0.test")));
            sslSocket.setSSLParameters(sslParameters);

            // ポイント4: TLS handshake が確立したら、Proxyが返してきた
            // サーバ証明書の CN, SAN を表示して、どんな値になるか確認できるようにしています。
            sslSocket.addHandshakeCompletedListener(new HandshakeCompletedListener() {
                @Override
                public void handshakeCompleted(HandshakeCompletedEvent event) {
                    try {
                        X509Certificate[] certs = (X509Certificate[]) event.getPeerCertificates();
                        for (int i = 0; i < certs.length; i++) {
                            System.out.println("cert[" + i + "] - Subject: " + certs[i].getSubjectX500Principal().getName());
                            Collection<List<?>> subjectAltNames = certs[i].getSubjectAlternativeNames();
                            if (Objects.isNull(subjectAltNames)) {
                                System.out.println("cert[" + i + "] - not contains Subject Alternative Names");
                                continue;
                            }
                            List<List<?>> generalNames = new ArrayList<>(subjectAltNames);
                            for (int j = 0; j < generalNames.size(); j++) {
                                List<?> generalName = generalNames.get(j);
                                if (generalName.size() != 2) {
                                    System.err.println("cert[" + i + "] - Subject Alternative Name[" + j + "] is unknown content: " + generalName);
                                    continue;
                                }
                                if (generalName.get(0) instanceof Integer && generalName.get(1) instanceof String) {
                                    final Integer type = (Integer) generalName.get(0);
                                    final String value = (String) generalName.get(1);
                                    if (type == 2) { // DNS name
                                        System.err.println("cert[" + i + "] - Subject Alternative Name[" + j + "] is DNS value of: " + value);
                                    } else {
                                        System.err.println("cert[" + i + "] - Subject Alternative Name[" + j + "] is unknown type: " + type);
                                    }
                                } else {
                                    System.err.println("cert[" + i + "] - Subject Alternative Name[" + j + "] is unknown content: " + generalName);
                                }
                            }
                        }
                    } catch (Exception e) {
                        System.err.println("Failed to print server certificates: " + e);
                    }
                }
            });

            sslSocket.startHandshake();

            // Send HTTPS GET request
            PrintWriter sslOut = new PrintWriter(sslSocket.getOutputStream(), true);
            BufferedReader sslIn = new BufferedReader(new InputStreamReader(sslSocket.getInputStream()));

            sslOut.print("GET / HTTP/1.1\r\nHost: plaintext-req.test\r\nConnection: close\r\n\r\n");
            sslOut.flush();

            // Print response
            while ((line = sslIn.readLine()) != null) {
                System.out.println(line);
            }

            sslSocket.close();
        }
    }
}

コンパイルと実行:

$ javac ConfusingCertificateProxiedClient.java

実行方法は後述します。

(4) Burp を使った検証

  1. Burp を起動し、Proxyのポート番号を8080番で開始(デフォルトなので、単にBurpを起動するだけでOK)
  2. ConfusingCertificateWebServer をポート番号8081番で起動
    $ java ConfusingCertificateWebServer 8081
    ConfusingCertificateWebServer started on port 8081
    Ctrl-C stops the server.
    
  3. ConfusingCertificateProxiedClient を実行し、127.0.0.1:8080 (= Burp) を proxy として 8081 番 (= ConfusingCertificateWebServer) にHTTPアクセス
    java ConfusingCertificateProxiedClient 127.0.0.1 8080 8081
    cert[0] - Subject: CN=localhost,OU=PortSwigger CA,O=PortSwigger,C=PortSwigger
    cert[0] - Subject Alternative Name[0] is DNS value of: localhost
    cert[1] - Subject: CN=PortSwigger CA,OU=PortSwigger CA,O=PortSwigger,L=PortSwigger,ST=PortSwigger,C=PortSwigger
    cert[1] - not contains Subject Alternative Names
    HTTP/1.1 200 OK
    Content-Type: text/plain
    
    Hello from ConfusingCertificateWebServer!
    

Burp が返すMITM用サーバ証明書の内容として、 CN/SAN 両方に localhost が埋め込まれていることを確認しました。

今回の検証用 Web Server / HTTP Client においては、Burp に localhost をホスト名として与えているのは HTTP Client の CONNECT メソッドの request line (= authority-form) のみです。

以上から、Burp は HTTP Client からの CONNECT メソッドの authority-form からホスト名を取り出し、MITM用サーバ証明書の Common Name と Subject Alternative Name に設定していることを確認しました。

補足:念の為 wireshark で loopback をキャプチャして、CONNECT メソッドの内容を確認してみます。
2025-08-19,burp-mitm-server-cert-origin-03.png

想定通り、authority-form のホスト名と Host: リクエストヘッダーのホスト名が異なる内容で送信していることを確認できました。

まとめ

Burp は HTTP Client からの CONNECT メソッドの authority-form からホスト名を取り出し、MITM用サーバ証明書の Common Name と Subject Alternative Name に設定していることを確認しました。

これはブラウザ側のサーバ証明書検証で、アドレスバーに入力されたURLのホスト名とサーバ証明書の内容が一致しているか検査していることを考えれば、ブラウザから見えるホスト名と一致させるための当然の挙動といえます。

Burp を使った作業では、特にスマホアプリの通信で Burp を通すときに、スマホ側のProxy設定でつまづきやすいです。例えば証明書検証エラーとなったときに、Burp 側がどういう仕様でMITM用のサーバ証明書を生成しているか知っていると、何かしらトラブルシュートに役立つかもしれません。

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?