CUBE SUGAR CONTAINER

技術系のこと書きます。

FreeBSD (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の FreeBSD でアセンブラからシステムコールを呼んでみる。 システムコールは、OS (ディストリビューション) のコアとなるカーネルがユーザ空間のプログラムに向けて提供しているインターフェースのこと。 ファイルの入出力など、ユーザープログラムは大抵のことはシステムコールを通じてカーネルにお願いしてやってもらうことになる。 ただ、普段は色々な API がラップして実体が見えにくいので、今回はアセンブラから直接呼んでみることにした。

使った環境は次の通り。

$ uname -sr
FreeBSD 12.0-RELEASE
$ nasm -v
NASM version 2.14.02 compiled on Aug 22 2019
$ ld -v
LLD 6.0.1 (FreeBSD 335540-1200005) (compatible with GNU linkers)

TL; DR

分かったことは次の通り。

  • 商用 UNIX の流れをくむ Unix 系 OS のシステムコールは ABI (Application Binary Interface) が x86 と x86-64 で異なる
    • x86 (32bit) ではスタックを使って引数を渡す
    • x86-64 (64bit) ではレジスタを使って引数を渡す

ちなみに Linux では、どちらもレジスタ経由でシステムコールの引数を渡していた点が異なる。

blog.amedama.jp

もくじ

下準備

あらかじめ、下準備として NASM (Netwide Assembler) をインストールしておく。

$ sudo pkg install nasm

NASM / x86 で exit(2) を呼び出すだけのプログラム

まずは、ハローワールドですらなく「終了するだけ」のプログラムを書いてみる。 FreeBSD には、プログラムを終了するためのシステムコールとして exit(2) がある。

最初は x86 (32bit) アーキテクチャのアセンブラを用いる。 FreeBSD で x86 のシステムコールに関するドキュメントとしては以下があった。

www.freebsd.org

以下が、上記のドキュメントにもとづいて x86 で exit(2) を呼び出すだけのアセンブラのソースコードとなる。 FreeBSD の x86 アセンブラでは、割り込み番号 0x80int 命令を発行するだけの関数経由でシステムコールを呼ぶことを想定しているらしい。 また、Linux の x86 のシステムコール呼び出しとは異なり、引数はシステムコールの識別子を除いてスタック経由で受け渡しされる。

global _start

section .text

_syscall:  ; FreeBSD expects to call system call via int 0x80 only function
  int 0x80
  ret

_start:
  ; exit system call (FreeBSD / x86)
  push dword 0  ; return code
  mov eax, 1  ; system call id (exit)
  call _syscall  ; call system call
  add esp, byte 4  ; restore ESP (DWORD x1)
  ret  ; don't reach here

x86 (32bit) 向けにビルドする

それでは、上記を実際にビルドして実行してみよう。

まずは NASM を使って 32bit の ELF (Executable and Linkable Format) としてアセンブルする。

$ nasm -f elf32 nop.asm
$ file nop.o
nop.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

上記をリンカを使って実行可能オブジェクトファイルにする。

$ ld -m elf_i386_fbsd -o nop nop.o
$ file nop
nop: ELF 32-bit LSB executable, Intel 80386, version 1 (FreeBSD), statically linked, not stripped

できたファイルを実行してみよう。

$ ./nop

何も表示されないけど、エラーにならずに実行できていることがうまくいっていることを示している。

プログラムの返り値についてもスタック経由で指定したゼロになっている。

$ echo $?
0

x86-64 (64bit) 向けにビルドする

では、次に先ほどのサンプルコードを x86-64 (64bit) 向けのアプリケーションとしてビルドしてみよう。 x86-64 は機械語のレベルでは x86 と後方互換性があるけど、どういった結果になるだろうか。

まずは 64bit の ELF としてアセンブルする。

$ nasm -f elf64 nop.asm

実行可能オブジェクトファイルとしてリンクする。

$ ld -m elf_amd64_fbsd -o nop nop.o

実行してみよう。

$ ./nop

特にエラーにならず実行できているので、うまくいっていそうだけど…?

返り値を見ると非ゼロの値が入っている。

$ echo $?
144

どうやら返り値の受け渡しの部分がうまくいっていないようだ。

NASM / x86-64 で exit(2) を呼び出すだけのプログラム

実は FreeBSD というか商用 UNIX の流れをくんだ Unix 系 OS は x86 と x86-64 でシステムコールの ABI が異なっている。 具体的には、x86 ではスタック経由で引数を渡していたのが x86-64 ではレジスタ経由で渡すことになった。 この問題については以下が分かりやすい。

stackoverflow.com

また、詳しくは以下のリポジトリでメンテナンスされている PDF についても参照のこと。

github.com

上記にもとづいて 64 ビットのアプリケーションとしてビルドできるサンプルコードを以下に示す。 これはシステムコールの識別子は異なるものの、前述した GNU/Linux の記事に出てきたサンプルコードとほとんど同じもの。 x86-64 においては Linux と商用 UNIX の子孫たちでシステムコールの呼び出し方は同じになっている。

global _start

section .text

_start:
  ; exit system call (FreeBSD / x86-64)
  mov rax, 1  ; system call id
  mov rdi, 0  ; return value
  syscall  ; call system call

実際に上記を 64 bit の実行可能オブジェクトファイルとしてビルドしてみよう。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ ld -m elf_amd64_fbsd -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (FreeBSD), statically linked, not stripped

できたファイルを実行する。

$ ./nop
$ echo $?
0

今度は返り値についてもゼロが設定されている。

ちなみに Linux では同じシステムコールでも x86 t x86-64 で識別子が異なったけど、FreeBSD の場合は変わらないらしい。

github.com

write(2) を追加したハローワールドでも比較してみる

呼び出すシステムコールが exit(2) だけだと味気ないので write(2) も追加してハローワールドも見ておこう。

x86 版 ABI のハローワールド

まずは x86 版 ABI を使ったハローワールドが以下の通り。

global _start

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_syscall:
  int 0x80
  ret

_start:
  ; write system call (FreeBSD / x86)
  push dword msg_len
  push dword msg
  push dword 1
  mov eax, 4
  call _syscall
  add esp, byte 12  ; restore ESP (DWORD x3)

  ; exit system call (FreeBSD / x86)
  push dword 0
  mov eax, 1
  call _syscall
  add esp, byte 4  ; restore ESP (DWORD x1)
  ret  ; don't reach here

x86 (32bit) 向けの実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf32 greet.asm
$ ld -m elf_i386_fbsd -o greet greet.o

できたファイルを実行する。

$ ./greet
Hello, World!
$ echo $?
0

ちゃんと動作しているようだ。

x86-64 版 ABI のハローワールド

続いて以下が x86-64 版 ABI のハローワールドになる。

global _start

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_start:
  ; write system call (FreeBSD / x86-64)
  mov rax, 4
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (FreeBSD / x86-64)
  mov rax, 1
  mov rdi, 0
  syscall

x86-64 (64bit) 向けの実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf64 greet.asm
$ ld -m elf_amd64_fbsd -o greet greet.o

できたファイルを実行する。

$ ./greet
Hello, World!
$ echo $?
0

うまくいっている。

FreeBSD の Linux バイナリ互換機能

ちなみに FreeBSD には Linux の ABI を使ったアプリケーションを実行するためのエミュレーション機能があるらしい。 実際に試してみよう。

エミュレーション機能はカーネルモジュールとして実行されているため linux.ko を読み込む。

$ sudo kldload linux.ko
$ kldstat | grep linux
 7    1 0xffffffff8284c000    39960 linux.ko
 8    1 0xffffffff82886000     2e28 linux_common.ko

Linux で動作する x86 版 ABI のサンプルコードを以下のように用意する。 レジスタで引数を渡しているので System V の x86 版 ABI とは異なる。

global _start

section .text

_start:
  mov eax, 1
  mov ebx, 0
  int 0x80

上記を 32 版の実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf32 nop.asm
$ ld -m elf_i386 -o nop nop.o

このままでは実行できない。

$ ./nop 
ELF binary type "0" not known.
-bash: ./nop: cannot execute binary file: Exec format error

そこで、brandelf コマンドを使って Linux 互換のバイナリとしてマークする。

$ brandelf -t Linux nop

実行してみる。

$ ./nop
$ echo $?
0

今度はちゃんと動作した。

GNU/Linux (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の GNU/Linux でシステムコールをアセンブラから呼んでみる。 システムコールは、OS (ディストリビューション) のコアとなるカーネルがユーザ空間のプログラムに向けて提供しているインターフェースのこと。

なお、アセンブラの実装に関しては以下の二つを試した。

  • NASM (Netwide Assembler)
  • GAS (GNU Assembler)

アセンブラには INTEL 記法と AT&T 記法という二つのシンタックスがある。 NASM はデフォルトで INTEL 記法を、GAS はデフォルトで AT&T 記法を使うことになる。

使った環境は次の通り。

$ uname -sr
Linux 4.15.0-65-generic
$ nasm -v
NASM version 2.13.02
$ as -v
GNU assembler version 2.30 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.30
$ ld -v
GNU ld (GNU Binutils for Ubuntu) 2.30
$ gcc --version
gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"

もくじ

下準備

あらかじめ、使うパッケージをインストールしておく。

$ sudo apt -y install nasm binutils gcc

NASM / x86 で exit(2) を呼び出すだけのプログラム

まずは、ハローワールドですらなく「終了するだけ」のプログラムから書いてみる。 GNU/Linux には、プログラムを終了するためのシステムコールとして exit(2) がある。

以下のサンプルコードは NASM (Netwide Assembler) 向けの x86 アセンブラで Linux の exit(2) を呼び出すだけのプログラムとなっている。 ちょっとゴチャゴチャしてるけど ; はコメントで、決まりきった内容も含むので実質的には 3 行しかない。

global _start

section .text

_start:
  ; exit system call (Linux / x86)
  mov eax, 1  ; system call id
  mov ebx, 0  ; return code
  int 0x80  ; soft interrupt

まず、global _start_start というシンボルを外部に公開することを示している。 そして、シンボル _start: 以下が実際の処理になっている。 section .text については、プログラムのテキスト領域がここからありますよ、という指示になっている。

そして、Linux / x86 (32bit) の場合、システムコールを呼ぶには以下の手順が必要になる。

  1. eax レジスタに呼びたいシステムコールの識別子を代入する
  2. 必要に応じて各レジスタにシステムコールの引数を代入する
  3. 割り込み番号 0x80 を指定して INT 命令を実行する

先ほどのサンプルコードの _start では、各行が上記の (1) ~ (3) に対応している。

  1. eax レジスタに exit(2) システムコールに対応する識別子の 1 を代入している
  2. exit(2) システムコールの第一引数として、プログラムの返り値 0ebx レジスタに代入している
  3. int 0x80 を呼ぶことで、ソフトウェア割り込み経由でシステムコールを呼び出している

x86 (32bit) 向けにビルドする

それでは、実際にサンプルコードをビルドしてみる。 今どきそうそう必要になることはないだろうけど、まずは x86 (32bit) 向けのバイナリにしてみよう。

nasm コマンドを使ってソースコードをコンパイル…じゃなくてアセンブルする。 出力フォーマットには 32bit 版の ELF (Executable and Linkable Format) を指定する。

$ nasm -f elf32 nop.asm

これで、32bit 版の再配置可能オブジェクトファイルができた。

$ file nop.o 
nop.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

リンカを使って上記を実行可能オブジェクトファイルにする。

$ ld -m elf_i386 -o nop nop.o
$ file nop
nop: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

ちなみに、エントリポイントとなるシンボルを指定したいときは -e オプションを使えば良い。 デフォルトでは _start が使われるので、先ほどのサンプルコードを使っている限り特に指定する必要はない。

できあがった実行可能オブジェクトファイルを実際に実行してみよう。

$ ./nop

何も起こらないけど、そもそも何もエラーにならずプログラムが終了している、という点が上手くいっていることを示している。

プログラムの返り値を確認しても非ゼロなので正常終了している。

$ echo $?
0

x86-64 (64bit) 向けにビルドする

x86-64 (64bit) アーキテクチャは x86 (32bit) アーキテクチャと後方互換性がある。 なので、先ほどの x86 向けのアセンブラは x86-64 向けにもビルドできる。

実際に、x86-64 向けにビルドしてみよう。 今度は 64bit 版の ELF としてアセンブルする。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

そして、リンカを使って実行可能オブジェクトファイルにリンクする。

$ ld -m elf_x86_64 -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

できたファイルを実行してみよう。

$ ./nop

こちらも、エラーにならずちゃんとプログラムが終了している。

返り値についても非ゼロなので正常終了しているようだ。

$ echo $?
0

NASM / x86-64 で exit(2) を呼び出すだけのプログラム

次こそはハローワールド…の前に、x86-64 アセンブラを扱っておく。 内容は先ほどと同じ exit(2) を呼び出すだけのプログラム。 純粋にシステムコールの呼び出し方の違いが分かりやすいはず。

以下が NASM 向けの x86-64 アセンブラで Linux の exit(2) を呼び出すだけのサンプルコード。 変わっているのは _start の中だけ。

global _start

section .text

_start:
  ; exit system call (Linux / x86-64)
  mov rax, 60  ; system call id
  mov rdi, 0  ; return code
  syscall  ; system call op code

x86 アセンブラとの違いを見ていくと、まず最初の 2 行で使うレジスタが異なっていることがわかる。 これは、x86-64 でレジスタが 64bit に拡張されたことに伴って名前が変わっている。

また、exit(2) システムコールを表す識別子も 1 から 60 に変わっていることがわかる。 この識別子はどこにあるの、という話なんだけど以下のように探せる。

$ locate syscalls | grep 32.h
/usr/src/linux-headers-4.15.0-62/arch/sh/include/asm/syscalls_32.h
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/.syscalls_32.h.cmd
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_32.h
$ locate syscalls | grep 64.h
/usr/src/linux-headers-4.15.0-62/arch/sh/include/asm/syscalls_64.h
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/.syscalls_64.h.cmd
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_64.h
$ grep sys_exit /usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_32.h
__SYSCALL_I386(1, sys_exit, )
__SYSCALL_I386(252, sys_exit_group, )
$ grep sys_exit /usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_64.h
__SYSCALL_64(60, sys_exit, )
__SYSCALL_64(231, sys_exit_group, )

ソースコードにあるテーブルを見ても良いかも。

linux/syscall_32.tbl at v4.15 · torvalds/linux · GitHub

linux/syscall_64.tbl at v4.15 · torvalds/linux · GitHub

そして 3 行目が決定的な違いで、x86-64 アセンブラでは syscall 命令という専用のオペコードが用意されている。

それでは、上記をビルドして実行してみよう。 x86-64 のアセンブラは x86 アーキテクチャでは動かないので、x86-64 向けにのみビルドする。

まずは NASM で x86-64 向けにアセンブリする。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

リンクして実行可能オブジェクトファイルにする。

$ ld -m elf_x86_64 -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

次の通り、ちゃんと動くことがわかる。

$ ./nop
$ echo $?
0

NASM / x86-64 で write(2) も呼んでハローワールドしてみる

次は呼び出すシステムコールを増やしてコンソールの標準出力にハローワールドを出してみよう。 変わるのは、出力する文字列を扱うことになるのと、write(2) システムコールを呼び出すところ。

以下がコンソールに Hello, World! と出力するサンプルコードになる。 新たに増えた .data のセクションはプログラムのデータ領域を示している。 ただし、今回の本題はシステムコールなので、ここでは深入りしない。

global _start

section .data
  msg db 'Hello, World!', 0x0A  ; 0x0A = \n
  msg_len equ $ - msg

section .text

_start:
  ; write system call (Linux / x86-64)
  mov rax, 1
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (Linux / x86-64)
  mov     rax, 60
  mov     rdi, 0
  syscall

上記のサンプルコードは先ほどに比べると syscall の呼び出しが増えている。 増えているのは write(2) の呼び出しで、これはファイルディスクリプタへのデータの書き込みをするシステムコールになっている。 mov rax, 1write(2) の識別子を rax レジスタに代入していて、その後ろがシステムコールの引数となっている。 rdi に代入している 1 は、標準出力のファイルディスクリプタの番号を示している。 rsi に代入している msg は出力する文字列のアドレスで、rdx に代入している msg_len が長さを示している。

それでは、上記を実際にビルドして実行してみよう。 先ほどと同じように実行可能オブジェクトファイルを作る。

$ nasm -f elf64 greet.asm
$ ld -m elf_x86_64 -o greet greet.o

できたファイルを実行すると、ちゃんと文字列が標準出力に表示される。

$ ./greet 
Hello, World!
$ echo $?
0

GAS / x86-64 で exit(2) を呼び出すだけのプログラム

続いては GAS (GNU Assembler) 向けのアセンブラを書いてみる。 書いていることは同じだけど記法が若干異なる。 NASM は INTEL 記法だったけど、GAS はデフォルトで AT&T 記法が使われる。 違いは、オペランドを書く順番が逆だったり、値やレジスタに記号が必要だったりする。

以下が AT&T 記法で書いた GAS 向けの exit(2) だけを呼び出す x86-64 アセンブラのサンプルコード。

.global _start

.text

_start:
  # exit system call (Linux / x86-64)
  mov $60, %rax
  mov $00, %rdi
  syscall

上記を as でアセンブルする。

$ as -o nop.o nop.s
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

リンクして実行可能オブジェクトファイルにする。

$ ld -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped_
$ ./nop
$ echo $?
0

GAS でプリプロセッサを使う

ちなみに GAS の場合はアセンブルに gcc を使うとプリプロセッサが使えるようだ。 プリプロセッサが使えると、例えばヘッダファイルを読み込んでシンボルを解決できたりする。

以下は GAS / x86-64 のハローワールドだけど、ヘッダとして <sys/syscall.h> を読み込んでいる。 ヘッダに含まれるシンボルはシステムコールを呼び出すところで $SYS_* という風に使っている。

#include <sys/syscall.h>

.global _start

.data
  message:
    .ascii "Hello, World!\n"
  message_len = . - message

.text

_start:
  # write system call
  mov $SYS_write, %rax
  mov $1, %rdi
  mov $message, %rsi
  mov $message_len, %rdx
  syscall

  # exit system call
  mov $SYS_exit, %rax
  mov $0, %rdi
  syscall

ファイル名の拡張子を大文字の .S にして gcc でアセンブルする。

$ gcc -c greet.S
$ ld -o greet greet.o

できたファイルを実行してみよう。

$ ./greet 
Hello, World!
$ echo $?
0

うまく動いているようだ。

参考

https://blog.packagecloud.io/eng/2016/04/05/the-definitive-guide-to-linux-system-calls/blog.packagecloud.io

python-livereload で Re:VIEW の執筆を捗らせてみる

普段、Sphinx でドキュメントを書くときは sphinx-autobuild というツールを使っている。 このツールを使うと、編集している内容をブラウザからリアルタイムで確認できるようになる。

blog.amedama.jp

今回は、上記のような環境が Re:VIEW でも欲しくて python-livereload というパッケージで実現してみた。 ちなみに、python-livereload は、前述した sphinx-autobuild が内部的に使っているパッケージの一つ。 利点としては、ブラウザ側に livereload 系のプラグインを入れる必要がない点が挙げられる。 これは、Web サーバの機能の中で livereload に使うスクリプトを HTML に動的に挿入することで実現している。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V          
Python 3.7.4

もくじ

Re:VIEW の原稿を用意する

まずは Re:VIEW の環境を用意しておく。 詳しくは以下のエントリを参照のこと。

blog.amedama.jp

python-livereload をインストールする

続いて python-livereload をインストールする。

$ pip install livereload

インストールすると livereload コマンドが使えるようになる。 基本的な機能であれば、このコマンドで完結することもある。

$ livereload --help
usage: livereload [-h] [--host HOST] [-p PORT] [-t TARGET] [-w WAIT]
                  [-o OPEN_URL_DELAY] [-d]
                  [directory]

Start a `livereload` server

positional arguments:
  directory             Directory to serve files from

optional arguments:
  -h, --help            show this help message and exit
  --host HOST           Hostname to run `livereload` server on
  -p PORT, --port PORT  Port to run `livereload` server on
  -t TARGET, --target TARGET
                        File or directory to watch for changes
  -w WAIT, --wait WAIT  Time delay in seconds before reloading
  -o OPEN_URL_DELAY, --open-url-delay OPEN_URL_DELAY
                        If set, triggers browser opening <D> seconds after
                        starting
  -d, --debug           Enable Tornado pretty logging

今回は、監視対象のファイルを指定する部分がちょっと複雑になったのでスクリプトから使うことにした。

監視・ビルド用のスクリプトを用意する

続いては python-livereload を使うスクリプトを用意する。 これは、ファイルを監視して、変更があったときはビルドを実行するというもの。

以下のようなスクリプトを用意した。 HTML の入ったディレクトリ以外を監視して、変更があったら rake web を実行するというもの。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os

from livereload import Server
from livereload import shell


def main():
    # livereload が Web サーバでホストするパス
    serve_path = 'webroot'
    # HTML をビルドするコマンド
    build_func = shell('rake web')
    # 監視対象のファイルパス
    watch_targets = ['*', '*/*']

    # パスがないときはあらかじめビルドしておく
    if not os.path.exists(serve_path):
        build_func()

    # 監視を開始する
    server = Server()
    for watch_target in watch_targets:
        server.watch(filepath=watch_target,
                     ignore=lambda path: path == serve_path,
                     func=build_func)
    server.serve(root=serve_path,
                 open_url_delay=1)


if __name__ == '__main__':
    main()

用意したスクリプトを実行すると、Web サーバが立ち上がって自動的にブラウザを開く。 監視対象の何らかのファイルを変更すると、ドキュメントがビルドされ直してブラウザがリロードされる。

$ python autobuild.py
[I 191009 00:17:14 server:296] Serving on http://127.0.0.1:5500
[W 191009 00:17:14 server:304] Use `open_url_delay` instead of `open_url`
[I 191009 00:17:14 handlers:62] Start watching changes
[I 191009 00:17:14 handlers:64] Start detecting changes
[I 191009 00:17:20 handlers:135] Browser Connected: http://127.0.0.1:5500/

ばっちり。

Rake のタスクにする

スクリプトとして実行するのでも良いんだけど、Rake のタスクにしておいた方が使うとき便利かもしれない。

次の通り Rake の設定ファイルを用意する。

$ cat << 'EOF' > lib/tasks/autobuild.rake 
require 'rake'

desc 'watch the directory and build if any files changed'
task :autobuild do
  sh('python autobuild.py')
end
EOF

これで rake autobuild するとスクリプトが実行されるようになる。

$ rake autobuild

捗りそうだ。

補足

本当は Ruby で完結させたくて、最初はその道を模索した。 ただ、ブラウザのプラグインを必要としない livereload 系の実装が Ruby には見当たらなくて。 加えて、Ruby に不慣れということもあって不本意ながら Python が混ざることになってしまった。 けどまあ、手間をほとんどかけずに実現できたのは良かったかな。

デジタル出版システム Re:VIEW を使ってみる

書籍の執筆環境として、最近は Re:VIEW の評判が良いので試してみることにした。 しばらく使い込んでみて良さそうだったら、既存の Sphinx の環境から移行するのもありかもしれない。 もちろん Sphinx もドキュメントを書くには良いツールなんだけど、はじめから書籍の執筆を試行しているか否かは Re:VIEW と異なる。

使った環境は次の通り。

$ sw_vers         
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ review version  
3.2.0

もくじ

Ruby をインストールする

Re:VIEW は Ruby で書かれたツールセットなので、インストールするのに Ruby が必要となる。 macOS のシステムにインストールされた Ruby でも大丈夫だとは思うけど、念のため rbenv で最新版を入れておく。

まずは Homebrew で rbenv をインストールする。

$ brew install rbenv ruby-build

現時点で最新版の Ruby をインストールする。

$ rbenv install 2.6.5

デフォルトのバージョンをインストールしたものに切り替える。

$ rbenv global 2.6.5

パスを通すためにシェルの設定ファイルに rbenv の初期設定用の記述を追加する。 以下は ZSH を使っている場合に、rbenv コマンドがあるときだけ初期化するというもの。

$ cat << 'EOF' >> ~/.zlogin
: "rbenv" && {
  which rbenv >/dev/null 2>&1
  if [ $? -eq 0 ]; then
    eval "$(rbenv init -)"
  fi
}
EOF

設定を追加したら読み込む。

$ source ~/.zlogin

Ruby のバージョンが切り替わればおっけー。

$ ruby --version
ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-darwin18]

Mac TeX をインストールする

Re:VIEW は PDF をビルドするのに TeX Live を必要とする。 そこで、macOS であれば Homebrew Cask で Mac TeX を入れておく。

$ brew cask install mactex

Re:VIEW をインストールする

準備ができたので Gem を使って Re:VIEW をインストールする。

$ gem install review

review コマンドが叩けるようになれば良い。

$ review version
3.2.0

プロジェクトを作る

まずは review-init コマンドでプロジェクトのひな形を作る。

$ review-init helloworld

これで、必要なファイル群ができた。

$ ls helloworld 
Gemfile     config.yml  images      sty
Rakefile    doc     layouts     style.css
catalog.yml helloworld.re   lib

できたディレクトリに移動する。

$ cd helloworld

Re:VIEW では .re ファイルに Re:VIEW の軽量マークアップ言語を使って文章を書いていく。 試しに適当な文章を追加しておこう。

$ cat << 'EOF' > helloworld.re
= Hello, World!

hello, world
EOF

HTML にビルドする

各形式には Rake のタスクを実行することでビルドできる。 タスクは内部的には review-* のコマンドを叩いている。

$ rake web

例えば web タスクであれば webroot ディレクトリに HTML の成果物がビルドされる。

$ ls webroot
helloworld.html images      index.html  style.css   titlepage.html

PDF にビルドする

同様に、rake pdf なら PDF がビルドできる。

$ rake pdf
$ file book.pdf
book.pdf: PDF document, version 1.5

EPUB にビルドする

電子書籍のフォーマットである EPUB も rake epub でビルドできる。

$ rake epub
$ file book.epub
book.epub: EPUB document

所感

設定ファイルなどを見た限りでも、Re:VIEW は書籍の執筆というドメインにかなり特化している印象を受ける。 電子書籍を書く、という目的であれば既存のドキュメンテーションツールをカスタマイズしていくよりも手間が少なく済みそうだ。

返り値のチェックでシェルスクリプトが止まらないようにする

シェルスクリプトで set -e しておくとコマンドの返り値が非ゼロ (エラー) のときにスクリプトを止めることができる。 この機能を使うと、コマンドの実行結果がエラーになった状態で処理が突き進んでしまうことを防止できる。 ただ、この機能は便利な反面、スクリプトが意図せず止まってしまうこともある。 今回は、それを回避する方法について。

使った環境は次の通り。

$ sw_vers     
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ bash --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin18)
Copyright (C) 2007 Free Software Foundation, Inc.

もくじ

set -e について

シェルスクリプトで set -e しておくと、コマンドの返り値が非ゼロ (エラー) のときに、処理をそこで止めることができる。 例えば以下のスクリプトでは bash -c "exit 1" のところで止まる。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの返り値が非ゼロなので異常終了
bash -c "exit 1"  # ここで停止する

# ここまで到達しない
echo "Hello, World"

上記を実行してみよう。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'

最後まで到達しないことが分かる。

一応、コマンドがゼロを返すパターンについても確認しておく。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの返り値がゼロなので正常終了
bash -c "exit 0"

# エラーがないので、ここまで到達する
echo "Hello, World"

上記を実行すると、今度は最後まで到達する。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 0'
+ echo 'Hello, World'
Hello, World

返り値が非ゼロになるコマンドを実行したい

ただ、この機能を有効にしていると困るときもある。 例えば、返り値が非ゼロになるコマンドをどうしても実行したい場合について。

まずは正攻法から。 次のように set +e を使って一時的に返り値のチェックを無効化できる。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

set +e  # 一時的に機能を無効化する
bash -c "exit 1"  # 返り値が非ゼロになるコマンドを実行する
set -e  # コマンドの実行が終わったら再び有効化する

# ちゃんとここまで到達する
echo "Hello, World"

上記を実行してみよう。 ちゃんと返り値が非ゼロになるコマンドも実行できていることがわかる。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 1'
+ set -e
+ echo 'Hello, World'
Hello, World

別解

ただ、上記は割と冗長なので、以下のようにもできるようだ。 一連のコマンドの返り値が必ずゼロになるようにするアプローチ。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 一連のコマンドとして返り値が必ずゼロになる
bash -c "exit 1" || true

# ちゃんとここまで到達する
echo "Hello, World"

上記を実行してみよう。 ちゃんと返り値が非ゼロになるコマンドも実行できている。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'
+ true
+ echo 'Hello, World'
Hello, World

返り値を使って条件分岐したい

続いては、特定のコマンドの返り値を使って条件分岐したいというパターン。

以下のサンプルコードでは、何もケアをしていないため返り値が非ゼロになると処理が止まってしまう。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの実行結果を元に処理を分岐させたい
bash -c "exit 1"  # しかし set -e があると停止してしまう

# 以下の分岐まで到達しない
if [ $? -eq 0 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 想定通り、非ゼロが返る時点で終了してしまう。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'

ゼロか非ゼロかしか見ない場合

まず、コマンドの返り値がゼロか非ゼロかしか見ないのであれば if に直接コマンドを埋め込んでしまえば良い。 if は元々内側のコマンドの返り値がゼロか非ゼロかで分岐する構文になっているため。

このアプローチを採用したサンプルコードは次の通り。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# if であれば直接コマンドを埋め込んでしまえば良い
if bash -c "exit 1"; then
  echo "ok"
else
  echo "ng"
fi

上記を実行した結果が次の通り。 set -e の影響を受けることなく意図通りに処理されていることがわかる。

$ bash errchk.sh
+ set -e
+ bash -c 'exit 1'
+ echo ng
ng

それ以外の返り値も確認したい場合

それ以外の返り値も確認したい場合には、ちょっと面倒になる。 まず、基本となるアプローチとしては直前に実行したコマンドの返り値が得られる変数 $? を使うことになる。 しかし、以下のように非ゼロになりうるコマンドの前後で無効化・有効化をしてしまうとうまくいかない。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# こうしたいんだけどダメなパターン
set +e
bash -c "exit 100"
set -e  # これもコマンドの実行なので $? が上書きされてしまう

# 分岐が意図通りにいかない
if [ $? -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 本来なら "ok" になってほしいところが "ng" になってしまった。 これは set -e によって $? の値が上書きされてしまったため。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 100'
+ set -e
+ '[' 0 -eq 100 ']'
+ echo ng
ng

そのため、次のようにコマンドの返り値を別の変数に退避しておく必要がある。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 返り値のチェックを止める影響を最小化する
set +e
bash -c "exit 100"
RET=$?  # コマンドの実行が終わったら返り値を変数に退避させる
set -e  # 退避したらエラーのチェックを戻す

# 分岐では退避させた変数を見る
if [ ${RET} -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 今度はちゃんとうまくいった。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 100'
+ RET=100
+ set -e
+ '[' 100 -eq 100 ']'
+ echo ok
ok

別解 (別プロセスのシェルで実行できる場合)

なお、別プロセスのシェルで実行できる処理であれば、コマンド置換を使って以下のように書くこともできる。 この方法であれば set -e されていない別シェルで実行しているのと同じ扱いになるので、一時的に set +e する必要がない。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンド置換を使って返り値を求める
RET=$(bash -c "exit 100"; echo $?)

# 分岐では退避させた変数を見る
if [ $RET -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 ちゃんとうまくいっているようだ。

$ bash errchk.sh 
+ set -e
++ bash -c 'exit 100'
++ echo 100
+ RET=100
+ '[' 100 -eq 100 ']'
+ echo ok
ok

また、このやり方であれば以下のように条件分岐の中に埋め込むこともできる。 これは if 文に test コマンドを使わず直接実行したいコマンドを埋め込んだ最初のパターンに近い簡便さになってると思う。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 条件分岐に埋め込むパターン
if [ $(bash -c "exit 100"; echo $?) -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

別解 (set +e を使わないアプローチ)

もうひとつ、最初の非ゼロのコマンドを実行するときの別解に似たやり方が次の通り。 コマンドの実行結果をワンライナーで変数に退避させることで set +e が不要になっている。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 変数を初期化する
RET=0
# 一連のコマンドとして返り値をゼロにするアプローチ
bash -c "exit 100" || RET=$?

# 分岐では退避させた変数を見る
if [ $RET -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

実行結果は次の通り。

$ bash errchk.sh 
+ set -e
+ RET=0
+ bash -c 'exit 100'
+ RET=100
+ '[' 100 -eq 100 ']'
+ echo ok
ok

いじょう。

色々な 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'

いじょう。

続: Docker コンテナ内で Docker ホストと同じユーザを使う

以前、Docker コンテナ内で Docker ホストと同じユーザを使う方法として、以下のような記事を書いた。 ちょっと強引だけど /etc/passwd/etc/group をコンテナからマウントすることで不整合をなくしてしまう、というもの。

blog.amedama.jp

ただ、上記のやり方だと不都合が出る場合もあったので、別のやり方についても試してみた。 どちらかというと、今回のやり方の方が後々の面倒がなくて良いかもしれない。 方法というのは、コンテナを起動するタイミングでコンテナ内にユーザを使って各種 ID などを引き継がせてしまう、というもの。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-65-generic

もくじ

ユーザ情報の書かれたファイルを読み込み専用でマウントする方法の問題点について

Docker のインストール方法については前述した記事の中でも扱っているので省略する。

先のエントリでは、次のようにして /etc/passwd/etc/group を読み込み専用でマウントすることで同じユーザを使えるようにしていた。

$ sudo docker run \
    --rm \
    -u "$(id -u $(whoami)):$(id -g $(whoami))" \
    -v $(pwd):/mnt/working \
    -v /etc/passwd:/etc/passwd:ro \
    -v /etc/group:/etc/group:ro \
    -v /home:/home \
    -it ubuntu:18.04

この方法を使うと、たしかに Docker コンテナ内で Docker コンテナと同じユーザが使えるようになる。

$ whoami
vagrant
$ id
uid=1000(vagrant) gid=1000(vagrant)

ただ、この方法には問題がないわけでもない。 例えば、次のようにして一旦 root ユーザで入り直す。

$ sudo docker run \
    --rm \
    -v $(pwd):/mnt/working \
    -v /etc/passwd:/etc/passwd:ro \
    -v /etc/group:/etc/group:ro \
    -v /home:/home \
    -it ubuntu:18.04

そして、試しにユーザのパスワードハッシュを次のようにして usermod(8) で書き換えてみる。

# usermod -p python3 -c "import crypt; from getpass import getpass; print(crypt.crypt(getpass(), crypt.METHOD_SHA512))" vagrant
usermod: cannot open /etc/passwd

しかし、エラーになってしまった。 パスワードハッシュは、現在では一般的に /etc/shadow に書き込まれるようになっている。 しかし、後方互換性を考えて /etc/passwd に書かれていても動作する。 そのため、結局のところ /etc/passwd にも書き込み権限が必要とされるらしい。 このように、読み込み専用でマウントしてしまうことは、後々面倒な問題を招きやすいようだ。

動的にユーザを追加・編集する

そこで、もう一つのやり方を試してみることにする。 具体的には、コンテナを起動するタイミングでユーザを追加して ID などを編集してしまう、というもの。

まずは Supervisord の設定ファイルを用意しておく。 これはコンテナが終了しないようにするためだけのもの。 何らか継続するプロセスであれば別のものを使っても良い。

$ cat << 'EOF' > supervisord.conf
[supervisord]
nodaemon=true
EOF

続いて Dockerfile を用意する。 必要なパッケージなどをインストールして設定ファイルをコピーしているだけなので、そんなに見るべきところはない。 コンテナが起動するときは docker-entrypoint.sh というシェルスクリプトを実行している。 この中でユーザを追加・編集することになる。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

# Use mirror repository
RUN sed -i.bak -e "s%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g" /etc/apt/sources.list

# Install prerequisite packages
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq --no-install-recommends \
      sudo \
      supervisor \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Boot process
COPY supervisord.conf /etc
COPY docker-entrypoint.sh /var/tmp
CMD bash -E /var/tmp/docker-entrypoint.sh
EOF

続いてが肝心のコンテナが起動するときに呼び出されるシェルスクリプト。 この中では変数を元にユーザを追加して ID の類を編集している。 そして、最終的に Supervisord が起動するようになっている。

$ cat << 'EOF' > docker-entrypoint.sh
#!/usr/bin/env bash

set -Ceux

assert_env_defined () {
  if [ ! -v $1 ]; then
    echo "please define '$1' environment variable"
    exit 1
  fi
}

: "Add non administrative user" && {

  : "Add user" && {
    assert_env_defined USER_NAME
    set +e  # avoid error check temporarily
    id ${USER_NAME}  # existence check
    if [ $? -ne 0 ]; then
      useradd -s /bin/bash -m ${USER_NAME}
    fi
    set -e  # enable error check
  }

  : "Set home directory" && {
    export HOME=/home/${USER_NAME}
  }

  : "Change user id" && {
    assert_env_defined USER_ID
    if [ $(id -u ${USER_NAME}) -ne ${USER_ID} ]; then
      usermod -u ${USER_ID} ${USER_NAME}
    fi
  }

  : "Change group id" && {
    assert_env_defined GROUP_ID
    if [ $(id -g ${USER_NAME}) -ne ${GROUP_ID} ]; then
      usermod -g ${GROUP_ID} ${USER_NAME}
    fi
  }

  : "Add common groups" && {
    usermod -aG sudo ${USER_NAME}
  }

}

: "Start supervisord" && {
  supervisord -c /etc/supervisord.conf
}
EOF

上記のコンテナを起動するための Docker Compose の設定ファイルを用意しておこう。

$ cat << 'EOF' > docker-compose.yml
version: "3"
  
services:
  override:
    image: example/override-ids
    build: .
    container_name: override
    environment:
      - USER_NAME
      - USER_ID
      - GROUP_ID
    volumes:
      - ./:/mnt/working
EOF

まずはコンテナイメージをビルドする。

$ sudo docker-compose build

そして、次のように現在のユーザ名や ID の類をシェル変数ごしに教えてやりながらコンテナを起動する。

$ USER_NAME=$(whoami) \
  USER_ID=$(id -u) \
  GROUP_ID=$(id -g) \
  sudo -E docker-compose up
...
override    | + : 'Start supervisord'
override    | + supervisord -c /etc/supervisord.conf
override    | 2019-10-02 14:54:43,036 CRIT Supervisor running as root (no user in config file)
override    | 2019-10-02 14:54:43,038 INFO supervisord started with pid 14

起動したコンテナにログインする。 このとき su(1) を使って追加されたユーザにログインし直す。

$ sudo docker exec -it override su - $(whoami)

すると、次のようにユーザ名や ID が引き継がれたように見える状態でコンテナが起動する。

$ whoami
vagrant
$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant),27(sudo)

試しに、何かファイルを作ってみよう。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt

ちゃんと Permission Denied にならずにファイルが作られた。

Docker ホストのファイルも、以下のようにユーザがちゃんと状態で表示されている。 これは、コンテナとホストでユーザ名や識別子が一致しているため。

$ ls -lF
total 20
-rw-rw-r-- 1 vagrant vagrant 466 Oct  2 14:39 Dockerfile
-rw-rw-r-- 1 vagrant vagrant 215 Oct  2 14:51 docker-compose.yml
-rw-rw-r-- 1 vagrant vagrant 978 Oct  2 14:46 docker-entrypoint.sh
-rw-rw-r-- 1 vagrant vagrant  13 Oct  2 14:53 greet-docker.txt
-rw-rw-r-- 1 vagrant vagrant  28 Oct  2 14:39 supervisord.conf

ちなみに、このやり方なら Docker for Mac でも使える。 めでたしめでたし。