CUBE SUGAR CONTAINER

技術系のこと書きます。

色々な Unix 系 OS の crypt(3) について調べたら面白かった話

今回は、色々な Unix 系 OS の crypt(3) について調べたら、過去の経緯などが分かって面白かったという話について。 crypt(3) というのは、標準 C ライブラリの libc ないし libcrypt で実装されている関数のこと。

調査した Unix 系 OS というのは、具体的には以下の通り。

  • GNU/Linux (Ubuntu 18.04 LTS)
$ uname -sr
Linux 4.15.0-65-generic
  • FreeBSD 12.0
$ uname -or
FreeBSD 12.0-RELEASE
  • OpenBSD 6.5
$ uname -sr
OpenBSD 6.5
  • macOS Mojave
$ uname -sr
Darwin 18.7.0

もくじ

TL; DR

  • crypt(3) が対応しているハッシュ化・暗号化方式は意外と OS によってバラバラ
  • crypt(3) でハッシュ化が使われ始めたのは Unix の歴史の中では意外と新しい
  • Python の passlib を使うとプラットフォームに依存せず MCF な文字列を生成できる

crypt(3) について

まずは crypt(3) の基本的な説明から。

前述した通り、crypt(3) は標準 C ライブラリの libc または libcrypt で実装されている関数を指している。 用途としては、ローカルのパスワード認証でシステムにログインするためのパスワードを、一般的にはハッシュ化するのに使う。

典型的には、shadow(5) の中に記述されている謎の文字列を生成するのに使われている。 補足しておくと shadow(5) は /etc/shadow のパスにある設定ファイルを指している。 システムは、ここに書かれている内容と、ユーザが入力した内容を比べることでログインさせて良いか判断する。

$ sudo cat /etc/shadow | grep ^$(whoami): | cut -d : -f 2
$6$hqcnshac$cIZ4Q88Lh/Mz9AybBGKuw8dYuTc/qZbicFX5NVmPVeGVYlwceHYTmuelzQp9i4m.5llRraxzCSdO5aAdmxHUF.

なお、この文字列はハッシュ化されているとはいえログインに使うパスワードから作られたものなので漏えいしないように気をつけよう。 上記は Vagrant で作ったパスワードが自明な環境の出力結果を載せている。

そして、この謎の文字列は、現在では次のようなフォーマットが一般的となっている。

$<id>$<salt>$<encrypted>

<id> はハッシュ化の方式を、<salt> はハッシュ化に使ったソルト、そして <encrypted> がハッシュ化されたパスワードを表している。 なんでハッシュ化 (Hashing) なのに暗号化 (Encrypted) なんだ?というのは後ほど分かってくる。

ちなみに、次のように <id><salt> の間に <option> を挟み込んだフォーマットも用いられることがある。

$<id>$<option>$<salt>$<encrypted>

なお、ここまでに使われている名前の後ろについた (3) みたいなカッコつきの数字は、man page のセクション番号を示している。 Unix の文脈では、何について述べているのかを明確にする目的でよく用いられる。 その内容について詳しく知りたいときは、システム上で次のようにすればマニュアルが読める。

$ man <section> <name>

例えば、今回扱う crypt(3) であれば、次のようにする。

$ man 3 crypt

GNU/Linux で crypt(3) を SHA-512 と一緒に使ってみる

何はともあれ、まずは GNU/Linux で crypt(3) を使ってみることにしよう。

以下の通り、パスワードの文字列を SHA-512 でハッシュ化するサンプルコードを用意した。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  // パスワードに使う文字列
  char *password = "password";
  // ハッシュ化の方式とソルト
  char *salt = "$6$salt$";
  // crypt(3) でハッシュ化された文字列
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドする。 libcrypt を使うため -l crypt オプションをつける。

$ gcc -Wall -o sha512.o crypt-sha512.c -l crypt

できたファイルを実行してみよう。 すると、先ほど見たような文字列が出力される。

$ ./sha512.o
$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.

このように、crypt(3) を使うことで shadow(5) に書かれる文字列を生成できる。

なお、C 言語がダルいときは次のように Python のインターフェースから使う手もある。

$ python3
...(snip)...
>>> import crypt
>>> crypt.crypt('password', '$6$salt$')
'$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.'

ここからが本題なんだけど、実は対応しているハッシュ化・暗号化の方式は OS によって異なる。 例えば、GNU/Linux で crypt(3) のマニュアルを読むと次のように記述されている。

ID  | Method
─────────────────────────────────────────────────────────
1   | MD5
2a  | Blowfish (not in mainline glibc; added in some
    | Linux distributions)
5   | SHA-256 (since glibc 2.7)
6   | SHA-512 (since glibc 2.7)

基本的には MD5 と SHA2 系で、ディストリビューションによっては Blowfish があるかも、という感じ。 それも、古い glibc (GNU 実装の libc) では SHA2 系も使えないことがわかる。 なお、ここでいう Blowfish は、共通鍵暗号方式の Blowfish をベースに考案されたハッシュ関数の Bcrypt を厳密には指している。

FreeBSD がサポートしているアルゴリズムについて

続いて FreeBSD でも試してみよう。 GNU/Linux でビルドしたのと同じソースコードを用意する。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "$6$salt$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

そして、これをビルドする。

$ cc -Wall -o sha512.o crypt-sha512.c -l crypt

実行すると、先ほどと全く同じ文字列が出力されることがわかる。

$ ./sha512.o
$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.

では、他にはどんなハッシュ化・暗号化方式に対応しているのだろうか。 マニュアルを確認してみよう。

$ man 3 crypt

すると、次のような記述が見つかる。 なるほど glibc と比較すると Blowfish が標準で入っていたり NT-Hash という方式に対応していることがわかる。

1.   MD5
2.   Blowfish
3.   NT-Hash
4.   (unused)
5.   SHA-256
6.   SHA-512

ただ、もうちょっとよく読むと、実は crypt(3) が出力するフォーマットには以下の 3 種類があるとも書かれている。

  • Extended
  • Modular
  • Traditional

ここまで見てきた $ で区切られたフォーマットは、どうやら Modular Crypt Format (MCF) というらしい。

そして、以下のような経緯があるようだ。

  • crypt(3) が実装されたのは AT&T UNIX V6 で rotor ベースのアルゴリズムを使っていた
  • 現在のスタイルになったのが V7 で DES ベースのアルゴリズムを使っていた
  • Traditional というのは V7 で実装された DES ベースのアルゴリズムを指している
  • DES ベースのアルゴリズムは今でも使える

注目すべきは、過去そして現在に至るまで DES ベースのアルゴリズムをサポートしているところ。 DES はハッシュ化に使われる一方向関数のアルゴリズムではなく、可逆な暗号化・復号に用いられるアルゴリズムだ。

AT&T UNIX V7 がリリースされたのは 1979 年のこと。 どうやら、crypt(3) でハッシュ化するというアイデアは、Unix の歴史の中ではそれなりに最近のものらしい。 上記のような経緯であれば、マニュアルの中で crypt(3) した文字列を encrypted と表現していたのも理解できる。

なお、FreeBSD に Modular Crypt Format (MCF) と MD5 が実装されたのは 1994 年のようだ。

svnweb.freebsd.org

FreeBSD で Traditional crypt (DES ベースの暗号化) を試してみる

それでは、実際に FreeBSD を使って Traditional crypt の挙動を試してみることにしよう。

Traditional crypt を試すには、先ほどからソルトの文字列を変更するだけで良い。 具体的には、$ もしくは _ から始まらない 2 文字をソルトとして指定する。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  // 2 文字のソルト
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドしよう。

$ cc -Wall -o des.o crypt-des.c -l crypt

そして、実行してみる。 すると、先頭にソルトがついた短い文字列が得られる。 $ が含まれないことからも、あきらかに MCF とはフォーマットが異なる。

$ ./des.o
XXq2wKiyI43A2

上記が昔の Unix で使われていたフォーマットらしい。

実は Traditional crypt は GNU/Linux でも使える

マニュアルには記述がないんだけど、実は glibc も Traditional crypt をサポートしている。 さっきのソースコードを GNU/Linux でも用意しよう。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

そしてビルドする。

$ gcc -Wall -o des.o crypt-des.c -l crypt

そして、実行してみる。 すると、さっきの FreeBSD と同じ文字列が得られた。

$ ./des.o
XXq2wKiyI43A2

OpenBSD では Blowfish しか使えない

GNU/Linux と FreeBSD がサポートしているアルゴリズムが分かったところで、次は OpenBSD も見てみよう。 実は、意外な結果が得られる。

まずは、Traditional crypt から試してみよう。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドして実行してみよう。

$ cc -Wall -o des.o crypt-des.c
$ ./des.o
can not crypt: Invalid argument

すると、Invalid argument とエラーになってしまった。

では、SHA-512 を使った MCF はどうだろうか。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "$6$salt$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドして実行してみる。

$ cc -Wall -o sha512.o crypt-sha512.c
$ ./sha512.o
can not crypt: Invalid argument

こちらもエラーになってしまった。

では、次に以下のようなサンプルコードを用意する。 ハッシュ化の方式は 2b で、その指定の直後に 12 という数字が入るなど何だかこれまでとちょっと異なる。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  // Blowfish 2b
  char *salt = "$2b$12$0123456789012345678901$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

実は上記は Blowfish のバージョン 2b というアルゴリズムになっている。

上記をビルドしてみよう。

$ cc -o blowfish.o crypt-blowfish.c

そして、実行すると、MCF っぽい文字列が得られる。

$ ./blowfish.o 
$2b$12$012345678901234567890urJuVDjqhVLN4JtdA/LMQb5YGJelo1WO

ソルトの文字列が長いのは、Blowfish 2b のソルトに必ず 22 文字という制約があるため。 また、得られた MCF っぽい文字列は、ソルトとハッシュ化された文字列の間が $ で区切られていないのも特徴的。 ちなみに、ハッシュ化の方式の指定の直後にある 12 という数字は、ハッシュ化を何度繰り返すかというストレッチングの指定だった。 ちなみに、2 の階乗で表現されているため 12 なら 212 = 4096 回らしい。

OpenBSD はバージョン 5.8 から他の方式を捨てた

どうして DES ベースの Traditional crypt や、MCF でも SHA-512 が使えなかったのだろうか。 理由は単純で、OpenBSD はバージョン 5.8 で他の方式をバッサリ捨てて Blowfish 一本に絞ったから。

リポジトリを見ると OpenBSD 5.7 には、まだ Traditional crypt で使うようなコードも残っている。

cvsweb.openbsd.org

しかし、OpenBSD 5.8 になるとコードがバッサリと切り捨てられて Blowfish で使う bcrypt(3) のコードしかない。

cvsweb.openbsd.org

ちなみに、現在の crypt(3) では、それに伴ってマニュアルも改定されている。 そこにはもう Encryption/Encrypted という単語はなく、Hashing という表現があるのみ。

man.openbsd.org

後方互換を捨てたからこそ使える表現なのかもしれない。 古くて脆弱なアルゴリズムを切り捨てる姿勢には、何よりセキュリティを重視する OpenBSD らしさを感じた。

なお、そもそも Bcrypt を考案したのが OpenBSD の開発チームらしい。

https://www.usenix.org/legacy/event/usenix99/provos/provos.pdf

macOS は Traditional crypt しか使えない

最後に、macOS についても確認しておこう。

まずは MCF の SHA-512 から。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "$6$salt$";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

上記をビルドする。

$ cc -Wall -o sha512.o crypt-sha512.c

そして実行する、と何かがおかしい。 MCF にしては短すぎるし $ も少ない。

$ ./sha512.o
$6FMi11BJFsAc

実は、上記では MCF を指定したつもりのソルトの先頭 2 文字が Traditional crypt で使うソルトとして認識されている。

改めて Traditional crypt のソースコードを用意しよう。

#define _XOPEN_SOURCE

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  char *password = "password";
  char *salt = "XX";
  char *hash = (char *) crypt(password, salt);

  if (hash == NULL) {
      perror("can not crypt");
      exit(EXIT_FAILURE);
  }

  printf("%s\n", hash);
  return EXIT_SUCCESS;
}

ビルドして実行すると、今度はちゃんと動作する。

$ cc -Wall -o des.o crypt-des.c
$ ./des.o
XXq2wKiyI43A2

ええーっ、て感じだけど macOS ではそもそも MCF が使えないようだ。

プラットフォームに依存しない MCF の生成について

ここまで見てきた通り、crypt(3) の実装はプラットフォームに依存する。 そこで、異なるプラットフォーム向けに MCF な文字列を生成する方法についても紹介しておく。

使ったのは Traditional crypt しか使えない macOS Mojave さん。

$ uname -sr
Darwin 18.7.0
$ python -V
Python 3.7.4

Python の passlib というサードパーティ製のパッケージをインストールする。 Blowfish が使いたいときは bcrypt についても入れておく。

$ pip install passlib bcrypt

インストールできたら Python のインタプリタを起動する。

$ python

試しに SHA-512 で生成してみよう。

>>> from passlib.hash import sha512_crypt
>>> sha512_crypt.hash('password', rounds=5000, salt='salt')
'$6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.'

ちゃんと、先ほど見たのと同じ文字列が得られた。

ちなみに、先ほど指定した rounds というオプションはストレッチング回数を指している。 実は MCF の SHA-512 はデフォルトで 5000 回ストレッチングされている。

それ意外のストレッチング回数を指定すると、次のように <option> の領域に rounds の指定が入るようになる。 これはその回数ストレッチングされた文字列ですよ、ということ。

>>> sha512_crypt.hash('password', rounds=5001, salt='salt')
'$6$rounds=5001$salt$tlQV3WMrkQpSw9nT3Wa2jOpUhHPxDtgcpasv4rOXxXXufOxtxgrzd4O67kS/localLJb2M2o0fCRdv/yfX0SJ/'

Traditional crypt が生成する文字列が得たいなら次のようにする。

>>> from passlib.hash import des_crypt
>>> des_crypt.hash('password', salt='XX')
'XXq2wKiyI43A2'

Blowfish なら次の通り。

from passlib.hash import bcrypt
>>> bcrypt.hash('password', rounds=12, salt='0123456789012345678901')
'$2b$12$012345678901234567890urJuVDjqhVLN4JtdA/LMQb5YGJelo1WO'

いじょう。