SST連載・解説記事

  1. HOMEHOME
  2. SSTtechlogSSTtechlog
  3. Javaと文字コード

SSTtechlog

対象読者Javaプログラマ

サンプルコードの動作環境Windows7 64bit 日本語版、Oracle JDK 1.8.0_31

はじめに

techlog4コマ01

illustrations by あおい海月

SSTではWebアプリケーションの脆弱性診断サービスを提供しており、診断時には診断対象のWebアプリケーションに対して擬似的な攻撃リクエストを送信し、SQLインジェクションやXSSに代表されるような脆弱性の有無を確認します。 診断時には、多様なパターンの攻撃リクエストを送信するために、Java言語で開発している専用のソフトウェアを使っています。 こうした脆弱性診断に用いるソフトウェアを開発するためにはいくつか注意点があります。 今回はその注意点の一つである、Javaでの文字コードの扱いについて、特に文字コードが不明なデータをJavaのString型として扱うためのテクニックを紹介したいと思います。

Javaと文字コード

はじめに、Javaプログラミングで文字コードを意識する時は、どんな時があるでしょうか。

・HTTP通信などネットワーク通信で送受信したデータを文字列として扱う時
・ファイル検索機能を作りたい時
・データベースに文字列を保存したり、取り出したい時 など

以上のように、何かしら文字列をファイルや外部システムとやりとりしたい時は、必ず文字コードを意識することになります。 特に脆弱性診断に用いるソフトウェアでは、Web上の様々な文字コードで記述されたコンテンツや、画像など文字以外のデータを扱う必要もあります。 文字コードの扱いが正確でないと、入力した文字とは異なる文字で保存される、あるいは表示されるなどのいわゆる「文字化け」につながります。

Javaプログラミングで文字コードを扱うには、「符号化文字集合」と「文字符号化方式」について知っておきましょう。


「符号化文字集合」とは?

コンピュータ上で効率的に「文字」情報を扱うため、文字一つ一つに対応する番号(符号)を割り振ったものが「符号化文字集合」(coded character set) です。 現在は「国際符号化文字集合」と呼ばれ、世界中の文字を収録する「Unicode」がよく使われています。 その他にも日本語文書で使われる文字を集めた「JIS X 0213」もあります。こちらは、日本語文書特有の歴史的な事情を考慮した、日本語圏で便利な文字集合になっています。

Javaの内部では、文字をUnicodeに従った番号(符号)で管理しています。


「文字符号化方式」とは?

ある符号化文字集合の文字で構成された文字列を、ファイルに保存したりネットワーク通信で送信するためには、ある規則にしたがって1と0のビット列に変換する必要があります。 またその逆に、ファイルを読んだりネットワーク通信で受信したデータを文字列に変換する必要もあります。
この時の変換ルールが「文字符号化方式」です。Unicode用の方式として"UTF-8"がよく使われます。 日本語のJIS X 0213に対応した方式としては "Shift_JIS"、"EUC-JP"、"ISO-2022-JP"があります。

本記事で「文字コード」と表記しているのは、Javaプログラミングにおける「文字符号化方式」を指し、Javaのドキュメントで「文字エンコーディング」と表記されるものです。


Javaでサポートしている文字符号化方式は?

Javaの内部では、文字をUnicodeに従った番号(符号)で管理しています。 しかしながら、ネットワーク通信やファイル操作のプログラムでは、原理的には8bitを1バイトとしたbyte型の配列(byte[]型)でデータをやり取りします。これらのデータを文字列として扱うために、「文字符号化方式」、つまり「文字コード」を用いてUnicodeと相互に変換する必要があります。

では、Javaではどんな文字符号化方式をサポートしているのでしょうか?以下のプログラムで、その一覧を出力できます。

CharsetList.java (UTF-8で保存して下さい) :

import java.nio.charset.Charset;

public class CharsetList {
    public static void main(String[] args) {
        // Charset.availableCharsets() で利用可能なCharsetと、その正規名のMapを取得できる。
        Charset.availableCharsets().forEach((name, charset) -> {
            System.out.printf("Name: %s%n", name);
           
            // Charset.aliases() で、別名として使える文字コード名のSetを取得できる。
            charset.aliases().forEach(alias -> {
                System.out.printf("  alias: %s%n", alias);
            });
        });
    }
}

コンパイル&出力結果:"Name"で文字コードの正規名が表示され、"alias"として別名として認識できる名前が表示されます。("#"以降は、出力結果に追記したコメントです

> javac -encoding utf-8 CharsetList.java
> java CharsetList
...(省略)...
Name: EUC-JP      # <- これが正規名
  alias: csEUCPkdFmtjapanese  # <- これがaliasで、charsetNameにこちらを指定してもよい。
  alias: x-euc-jp
  alias: eucjis
  alias: Extended_UNIX_Code_Packed_Format_for_Japanese
  alias: euc_jp
  alias: eucjp
  alias: x-eucjp
...(省略)...
Name: ISO-8859-1
  alias: 819
  alias: ISO8859-1
  alias: l1
  alias: ISO_8859-1:1987
  alias: ISO_8859-1
  alias: 8859_1
  alias: iso-ir-100
  alias: latin1
  alias: cp819
  alias: ISO8859_1
  alias: IBM819
  alias: ISO_8859_1
  alias: IBM-819
  alias: csISOLatin1
...(省略)...
# 他にも、サポートされている正規名とalias名が沢山表示されます。後述のJavaのドキュメントと見比べてみてください。

Javaのドキュメント上は、以下でサポートされているエンコーディングの一覧を確認できます。

・"Java Platform Standard Edition 8ドキュメント"-> "Java(tm)の国際化サポート"-> "サポートされているエンコーディング"
http://docs.oracle.com/javase/jp/8/docs/technotes/guides/intl/encoding.doc.html

Javaでの文字化けを体験

それでは、実際にJavaプログラムでの文字化けを体験してみます。 以下のJavaソースをコンパイルし、実行してみます。

Mojibake.java (UTF-8で保存して下さい) :

import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class Mojibake {

    public static void main(String[] args) {
        String source = "hello,こんにちは";
       
        // "hello,こんにちは" という、コンパイルされてUnicodeになった文字列を、複数の文字コードでbyte[]に変換します。
        System.out.printf("convert \"%s\" to byte[] using...", source);
        Arrays.asList("EUC-JP", "ISO-2022-JP", "ISO-8859-1", "Shift_JIS", "US-ASCII", "UTF-8", "windows-31j").forEach(
                charset -> {
                    System.out.printf("%nencode:[%s] ->", charset);
                    try {
                        // 文字コードでbyte[]に変換し、その内容を16進数で出力します。
                        byte[] encoded = source.getBytes(charset);
                        for (byte v : encoded) {
                            System.out.printf(" %x", v);
                        }
                       
                        // byte[]を、わざと全て"UTF-8"を使ってUnicodeに変換してみます。
                        System.out.printf("%ndecode:[UTF-8] -> %s%n", new String(encoded, "UTF-8"));
                    } catch (UnsupportedEncodingException e) {
                        e.printStackTrace();
                    }
                });
    }
}

以下がコンパイル&実行結果です。("#"以降は、出力結果に追記したコメントです)元の文字列が、文字コードに応じて様々なbyte[]型のデータになる様子が確認できます。また、意図的に"UTF-8"で文字列に戻しているため、文字化けせずに元の文字列に戻せたのは"UTF-8"でbyte[]型に変換したケースだけということを確認できます。

>javac -encoding utf-8 Mojibake.java
>java Mojibake
convert "hello,こんにちは" to byte[] using...
encode:[EUC-JP] -> 68 65 6c 6c 6f 2c a4 b3 a4 f3 a4 cb a4 c1 a4 cf
decode:[UTF-8] -> hello,????????

# → EUC-JPでbyte[]型に変換したのを、UTF-8でStringに変換したら、日本語文字部分が文字化けした。

encode:[ISO-2022-JP] -> 68 65 6c 6c 6f 2c 1b 24 42 24 33 24 73 24 4b 24 41 24 4f 1b 28 42
decode:[UTF-8] -> hello, $B$3$s$K$A$O (B

# → ISO-2022-JPでbyte[]型に変換したのを、UTF-8でStringに変換したら、日本語文字部分が文字化けした。

encode:[ISO-8859-1] -> 68 65 6c 6c 6f 2c 3f 3f 3f 3f 3f
decode:[UTF-8] -> hello,?????

# → ISO-8859-1でbyte[]型に変換した時点で日本語部分が0x3F="?")になってしまい、UTF-8でStringに変換したら、日本語部分が"?"になってしまった。

encode:[Shift_JIS] -> 68 65 6c 6c 6f 2c 82 b1 82 f1 82 c9 82 bf 82 cd
decode:[UTF-8] -> hello,????????

# → Shift_JISでbyte[]型に変換したのを、UTF-8でStringに変換したら、日本語文字部分が文字化けした。

encode:[US-ASCII] -> 68 65 6c 6c 6f 2c 3f 3f 3f 3f 3f
decode:[UTF-8] -> hello,?????

# → US-ASCIIでbyte[]型に変換した時点で日本語部分が0x3F="?")になってしまい、UTF-8でStringに変換したら、日本語部分が"?"になってしまった。

encode:[UTF-8] -> 68 65 6c 6c 6f 2c e3 81 93 e3 82 93 e3 81 ab e3 81 a1 e3 81 af
decode:[UTF-8] -> hello,こんにちは

# → UTF-8でbyte[]型に変換したのを、UTF-8でStringに変換したので、元の文字に戻せた。

encode:[windows-31j] -> 68 65 6c 6c 6f 2c 82 b1 82 f1 82 c9 82 bf 82 cd
decode:[UTF-8] -> hello,????????

# → windows-31jでbyte[]型に変換したのを、UTF-8でStringに変換したら、日本語文字部分が文字化けした。

ここで着目したいのが、ISO-8859-1とUS-ASCIIでbyte[]型に変換したケースです。

encode:[ISO-8859-1] -> 68 65 6c 6c 6f 2c 3f 3f 3f 3f 3f
encode:[US-ASCII] -> 68 65 6c 6c 6f 2c 3f 3f 3f 3f 3f

「こんにちは」の部分が全て 0x3F に変換されてしまっています。 いずれの文字コードも英語圏のため、Unicodeでの「こんにちは」の各文字符号に対応するマッピングを持っていません。 このため、0x3F="?"に変換されてしまったことが分かります。


Javaプログラム実行時の、デフォルトの文字コードは?

上記 Mojibake.java では文字コードを意図的に指定するため、"String.getBytes(String charsetName)"や"new String(byte[] bytes, String charsetName)"を使いました。

"String.getBytes(String charsetName)"
http://docs.oracle.com/javase/jp/8/docs/api/java/lang/String.html#getBytes-java.lang.String-

"new String(byte[] bytes, String charsetName)"
http://docs.oracle.com/javase/jp/8/docs/api/java/lang/String.html#String-byte:A-java.lang.String-

ではこれらを使わない場合は、デフォルトの文字コードはどのように決定されるのでしょうか?

答えから言うと、"file.encoding"というJavaシステムプロパティの値が使われます。この値はjavaコマンド実行時に "-Dfile.encoding=(charsetName)" というオプションで変更することが可能です。

以下のJavaソースをコンパイル・実行することで、デフォルトの文字コードを確認できます。

WhatIsDefaultCharset.java:

import java.nio.charset.Charset;

public class WhatIsDefaultCharset {
    public static void main(String[] args) {
        System.out.printf("default charset is [%s]%n", Charset.defaultCharset().displayName());
        String fileEncoding = System.getProperty("file.encoding");
        System.out.printf("file.encoding = [%s]%n", fileEncoding);
        System.out.printf("Charset.displayName() from file.encoding = [%s]%n", Charset.forName(fileEncoding)
                .displayName());
    }
}

コンパイル&実行結果:("#"以降は、出力結果に追記したコメントです

> javac WhatIsDefaultCharset.java
> java WhatIsDefaultCharset

# "Charset.defaultCharset().displayName()" の結果は "windows-31j"
default charset is [windows-31j]

# Java実行時のシステムプロパティ "file.encoding" から取得した値は "MS932"
file.encoding = [MS932]

# Charset.forName("MS932") した結果のCharsetインスタンスのdisplayName()を表示してみると、
# Charset.defaultCharset().displayName() と同じ結果になった。
Charset.displayName() from file.encoding = [windows-31j]

Charset.defaultCharset()は内部的には"file.encoding"から取得した値を使ってCharsetのインスタンスを取得しています。このため、Charset.defaultCharset()と、"file.encoding"から作ったCharsetインスタンスの"displayName()"は同じ文字コード名になっています。

byte[]型のデータを文字化けせずに保存するには?

ではいよいよ本題です。文字コードが不明なbyte[]型のデータを文字列として扱い、文字化けせずに保存する方法について説明します。

ネットワーク通信やファイル操作のプログラムでは、外部からのデータをbyte[]型として扱います。 このとき、byte[]型のデータをString型に変換して文字列として操作した後、保存する場面によく遭遇します。

例として、JPEG画像を返すHTTPのレスポンスデータを示します。

HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Length: 1234

(JPEG画像のデータ)

このレスポンスデータ全体からHTTPのレスポンスコードやContent-Type, Content-Lengthなどの情報を取り出したい、という課題があるとします。 JavaのString型に変換して、文字列として操作するのが近道ですが、JPEG画像のデータが含まれており、どの文字コードが適切か分かりません。 このような場合に、以下の2つのポイントをおさえることで、仮の文字コードを指定して処理することができます。

ポイント1:最低限扱いたい文字種を確認

実際に利用するプロトコルの仕様を確認し、文字コードが不明な状態で処理するために最低限扱えなければならない文字種をチェックしてみてください。 HTTP通信のレスポンス処理では"Content-Type"や"Content-Length"などを探すため、半角英数・記号までが扱えればそれで十分なケースも多いです。

ポイント2:8bitを1バイトとして、0-255全ての8bit値に対して1:1で文字を対応させている文字コードを探す。

以下のような操作を行う場合に、重要なポイントです。

byte[]型(入力) -> String -> 文字列操作 -> byte[]型 -> ファイル保存やネットワーク送信

このプロセスを細かく見ると、以下の流れになっています。

・step1. byte[]型から、仮の文字コードを使ってString型に変換=Unicodeにマッピング

byte[]型: 0x00 0x01 0x02 ... 0x41 0x42 0x43 ...

→ 例: new String(byte[] bytes, String charsetName)

→ Unicode: "xx" "xx" "xx" ... "A" "B" "C"
("xx" はcharsetName=文字コードに依存)

・step2. String型にて、必要な文字列操作を行う。

・step3. String型から、仮の文字コードを使ってbyte[]型に変換

Unicode: "xx" "xx" "xx" ... "A" "B" "C"

→ 例:String.getBytes(String charsetName)

→ byte[]型: 0x00 0x01 0x02 ... 0x41 0x42 0x43 ...

・この時、元のbyte[]型(入力)と同じデータに戻る必要があります。

分解した流れから、仮の文字コードとして必要な条件が見えてきます。

1. step1とstep3とでは、同じ文字コードを指定する必要がある。異なる文字コードを指定してしまうと、異なる変換方式が混在するため、step3の結果がstep1の入力と異なってしまう。

2. 1バイトの0から255まで、全ての値に対して、Unicodeの1文字がマッピングされている文字コードが良い。

  • 1. 複数バイトで1文字にマッピングする文字コードの場合、1文字を構成する複数バイトの途中でデータが切れている場合のマッピングが予測できません。そのため1バイトを1文字にマッピングする文字コードが安全です。

  • 2. 0から255までの全てのbyte値について、Unicodeの1文字にマッピングする必要があります。0-127までしかマッピングしないような文字コードだと、128-255の範囲がstep1で文字化けしてしまい、step3で戻せなくなってしまいます。

"ISO-8859-1"について

上記2つのポイントを満たすのにはJavaでは"ISO-8859-1"という文字コードを使うのが便利です。(厳密には他にもある筈ですが、Web上で検索してみても、よく使われているのはこの文字コードのようです)

"ISO-8859-1"は、0-255までの8bitの値に1:1で文字を対応させています。そのため画像データなどを"ISO-8859-1"でString型にしても、1文字1文字は元の1バイトの値に戻せる、つまり

byte[] => String => byte[]

の変換で、最初のbyte[]と最後のbyte[]の内容が一致します。また欧州文字圏で使われていて、プロトコルやプログラミングで使われる基本的な半角英数・記号に対応しているため、ポイント1・ポイント2の両方を満たせる文字コードになります。

逆に間違えやすいのが、"US-ASCII"(or "ASCII")や"UTF-8"などを使うケースです。これらはポイント1を満たせますが、"US-ASCII" は 128以上の8bit値に対して1:1ではマッピングできていませんし、"UTF-8"もASCII範囲は問題ありませんが、それ以外は複数バイトで1文字に対応させる符号化方式で、両方共ポイント2を満たせません。

それでは実際に、0-255までの8bit値をStringに変換し、さらにbyte[]型に変換し、元の値が保存されているかサンプルコードで確認してみましょう。

BinarySafeConversion.java (UTF-8で保存して下さい):

import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class BinarySafeConversion {

    // 0x00 - 0xFF までのbyte[]型
    public final static byte[] src = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
            19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45,
            46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
            73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99,
            100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120,
            121, 122, 123, 124, 125, 126, 127, -128, -127, -126, -125, -124, -123, -122, -121, -120, -119, -118, -117,
            -116, -115, -114, -113, -112, -111, -110, -109, -108, -107, -106, -105, -104, -103, -102, -101, -100, -99,
            -98, -97, -96, -95, -94, -93, -92, -91, -90, -89, -88, -87, -86, -85, -84, -83, -82, -81, -80, -79, -78,
            -77, -76, -75, -74, -73, -72, -71, -70, -69, -68, -67, -66, -65, -64, -63, -62, -61, -60, -59, -58, -57,
            -56, -55, -54, -53, -52, -51, -50, -49, -48, -47, -46, -45, -44, -43, -42, -41, -40, -39, -38, -37, -36,
            -35, -34, -33, -32, -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15,
            -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1 };

    // 入力となるbyte[]型データと文字コード名を受け取り、byte[] -> String -> byte[] に戻した時、入力データと一致するかチェックする。
    public static void validateConversion(byte[] src, String charsetName) throws UnsupportedEncodingException {
        System.out.println("Validating charsetName = [" + charsetName + "]:");
        String s = new String(src, charsetName);
        byte[] dst = s.getBytes(charsetName);
        if (src.length != dst.length) {
            System.out.println("  result byte[] length is not equals to source byte[] length.");
            return;
        }
        for (int i = 0; i < src.length; i++) {
            if (src[i] != dst[i]) {
                System.out.printf("  result byte[%d] is not equals to source byte[%d].%n", i, i);
                return;
            }
        }
        System.out.println("  SUCCESS");
    }

    // いくつかの文字コードで、0x00 - 0xFF が壊れないかチェックする。
    public static void main(String[] args) {
        System.out.println("0x00 to 0xFF : lenght = " + src.length);
        Arrays.asList("EUC-JP", "ISO-2022-JP", "ISO-8859-1", "Shift_JIS", "US-ASCII", "UTF-8", "windows-31j").forEach(
                charset -> {
                    try {
                        validateConversion(src, charset);
                    } catch (UnsupportedEncodingException e) {
                        e.printStackTrace();
                    }
                });
    }
}

コンパイル&実行結果:("#"以降は、出力結果に追記したコメントです

> javac -encoding utf-8 BinarySafeConversion.java
> java BinarySafeConversion
0x00 to 0xFF : lenght = 256

# EUC-JPで変換してみると、出力のbyte[]型の長さが異なってしまいました。→変換失敗
Validating charsetName = [EUC-JP]:
  result byte[] length is not equals to source byte[] length.

# ISO-2022-JPで変換してみると、出力のbyte[]型の長さが異なってしまいました。→変換失敗
Validating charsetName = [ISO-2022-JP]:
  result byte[] length is not equals to source byte[] length.

# ISO-8859-1で変換してみると、出力と入力が一致し、変換に成功したことが確認できます。
Validating charsetName = [ISO-8859-1]:
  SUCCESS

# Shift_JISで変換してみると、出力のbyte[]型の長さが異なってしまいました。→変換失敗
Validating charsetName = [Shift_JIS]:
  result byte[] length is not equals to source byte[] length.

# US-ASCIIで変換してみると、出力の128文字目が入力と異なり、そこで変換失敗と判定されました。
Validating charsetName = [US-ASCII]:
  result byte[128] is not equals to source byte[128].

# UTF-8で変換してみると、出力のbyte[]型の長さが異なってしまいました。→変換失敗
Validating charsetName = [UTF-8]:
  result byte[] length is not equals to source byte[] length.

# windows-31jで変換してみると、出力のbyte[]型の長さが異なってしまいました。→変換失敗
Validating charsetName = [windows-31j]:
  result byte[] length is not equals to source byte[] length.

以上より、byte[]型 -> String -> byte[]型という変換で、0x00 - 0xFF全ての値が入力と同じになるのは "ISO-8859-1"だけということを確認できました。

まとめ

Javaでbyte[]型を文字列として扱うには、文字コードの理解が必須です。HTTPなどのネットワーク通信など文字コードが不明なデータを文字列として処理する際は、以下の点に注意します。

・最低限処理したい文字種は何かを確認
・0x00 - 0xFF と1:1対応が必要か確認

Javaでは半角英数・記号を扱えて、1:1対応が必要な場合は"ISO-8859-1"が使えます。

参考資料

Amazon.co.jp: 文字コード「超」研究 改訂第2版:深沢千尋: 本
http://www.amazon.co.jp/dp/4899772939/

Amazon.co.jp: プログラマのための文字コード技術入門 (WEB+DB PRESS plus) (WEB+DB PRESS plusシリーズ):矢野 啓介: 本
http://www.amazon.co.jp/dp/477414164X/

(2015年6月30日更新)

Webセキュリティをざっくり理解するための3つのMAP

Page Top