Web Security の勉強をしていると、Burp Suite を使ってHTTPやHTTPS通信の内容を調べることが多いと思います。
Burp Suite は暗号化されたHTTPS通信についても、MITM(man in the middle)の仕組みを使ってBurp自身が通信先のHTTPSサーバであるかのように振る舞い、それにより暗号化を一度ほどいて、平文のHTTPリクエスト/レスポンスを記録 & 表示してくれます。
Burp Suite のMITMではアクセス先のサーバ毎に Burp 自身でサーバ証明書が都度生成されます。
今回は、「この時生成されるサーバ証明書の Common Name ってどこからやってくるのか?」という疑問 についてJavaのサーバ/クライアントコードを使って調べてみました。
調査環境:
- OS: Windows 11 Pro (64bit)
- Burp Suite: Community Edition v2025.7.3
- Java: Adoptium Temurin JDK 21 (64bit)
結論
先に結論だけ書くと、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を設定する」とはどういうことか、パケットレベルで解説 - セキュアスカイプラス )
- Proxyに接続し、平文で RFC7230 : authority-form のHTTPリクエストを送信(
CONNECT
メソッド)CONNECT www.example.com:80 HTTP/1.1
- Proxy から
HTTP/1.1 200
が返されたら、同じ socket 通信でTLSハンドシェイクを開始 - 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 と個別ホスト名を混在させ、どれが採用されたか区別しやすくした
手順:
- RSA 2048bit の秘密鍵を生成
$ openssl genpkey -algorithm RSA -out server.key -pkeyopt rsa_keygen_bits:2048
- この後の
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
- 自己署名証明書の作成と 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
- 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サンプルコードを準備します。
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 にアクセスする(安全ではありません)」をクリックして強制的にアクセスさせます。
アドレスバーに「保護されていない通信」と表示されるので、そこからサーバ証明書の内容を確認してみます。
→ req-x509-with-san.conf
で設定したとおり、全てバラバラのホスト名の証明書となっていることを確認できました。
(3) HTTP Client 用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 を使った検証
- Burp を起動し、Proxyのポート番号を8080番で開始(デフォルトなので、単にBurpを起動するだけでOK)
- ConfusingCertificateWebServer をポート番号8081番で起動
$ java ConfusingCertificateWebServer 8081 ConfusingCertificateWebServer started on port 8081 Ctrl-C stops the server.
- 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
メソッドの内容を確認してみます。
想定通り、authority-form のホスト名と Host:
リクエストヘッダーのホスト名が異なる内容で送信していることを確認できました。
まとめ
Burp は HTTP Client からの CONNECT
メソッドの authority-form からホスト名を取り出し、MITM用サーバ証明書の Common Name と Subject Alternative Name に設定していることを確認しました。
これはブラウザ側のサーバ証明書検証で、アドレスバーに入力されたURLのホスト名とサーバ証明書の内容が一致しているか検査していることを考えれば、ブラウザから見えるホスト名と一致させるための当然の挙動といえます。
Burp を使った作業では、特にスマホアプリの通信で Burp を通すときに、スマホ側のProxy設定でつまづきやすいです。例えば証明書検証エラーとなったときに、Burp 側がどういう仕様でMITM用のサーバ証明書を生成しているか知っていると、何かしらトラブルシュートに役立つかもしれません。