プログラムでユーザアカウントを追加するための調査

SPECIAL


ユーザアカウントの仕組み

Linux では、ユーザアカウントを /etc/passwd で行っています。

このファイルでは1行ごとに、ユーザ名、暗号化済みパスワード、Shell やホームディレクトリといった情報、所属グループなどが記述されています。このファイルは誰でも読み取ることができるようになっていて、このファイルを見れば、どのようなユーザが登録されているかがわかるようになっています。パスワードも記録されていますが、復元できないような暗号化されているため、他の人にパスワードを知られる心配もありません。

と、言いたいところなのですが、最近のコンピュータ技術の向上なども影響して、総当りでパスワードをチェックしていくなどが可能になってきたために必ずしも安心とはいえない今日この頃です。そこで、Shadow Password という仕組みが現在の Linux では一般的になっているようです。

 

この Shadow Password というのは、文字通り、/etc/passwd からパスワード部分を隠す仕組みです。

/etc/passwd のパスワードの部分は、x という文字で埋めておいて、別のファイル /etc/shadow に、そのアカウントの暗号化済みパスワードを保存するという方式です。旧来の /etc/passwd と違う点は、/etc/passwd はすべてのユーザが参照できるようになっていたのに対して、/etc/shadow は root 権限でしか参照できないようになっているという点です。これによって、従来の /etc/passwd を保ちながら、高い安全性を得ることができるようになっているというわけです。

 

このあたりの仕組みはおいといて、コマンドラインからユーザ登録をする観点で見てみましょう。

Linux には useradd というアカウントを追加するためのコマンドが用意されています。引数にさまざまな情報を与えることでユーザを追加することができるので、一見、スクリプトなどから簡単にアカウントを追加できそうな感じですが、ひとつ大きな壁にぶつかってしまいました。

それは、useradd が要求するパスワードは 暗号化済みのパスワード であるということです。

 

Perl の crypt 関数を使ってみる

man useradd として、useradd コマンドのマニュアルを読んでみると、-p オプションにあたえるパスワードは crypt 関数によって暗号化されている必要があると書いてあります。

Linux では CGI でもおなじみの Perl にその crypt 関数が用意されているようなので、それを使って実験してみることにしました。

#!/usr/bin/perl

print crypt($ARGV[0], 'EZ');

というスクリプト crypt.pl を作成して、chmod +x crypt.pl を実行すれば準備完了です。あとはコマンドラインからでも、./crypt.pl [password] を実行することで、自動的に crypt 関数を通した結果を得ることができます。

crypt 関数の構造としては、

crypt ( CLEAR-PASSWORD, SALT )

という構造になります。CLEAR-PASSWORD の欄には、暗号化したいパスワードを指定します。SALT の値は何でもいいようで、適当な2文字を使用します。これによって暗号化済みパスワードを多様化するようですね。SALT で使用できる文字はアルファベットと数字、ピリオドとスラッシュのようです。

これによって戻ってきた文字列が、暗号化されたパスワードということになります。

 

では実際に、これでうまくいくのか試してみましょう。

useradd -p `./crypt.pl abcdefgh` testuser

コマンドラインからこのように入力して、useradd に対してユーザ名 testuser と、暗号化済みパスワードとして ./crypt.pl abcdefgh の実行結果 (つまり、パスワード "abcdefgh" の暗号化済みパスワード) をわたします。さて、うまくいったでしょうか。実際に testuser でログインしてみます。

どうでしょう、なんとうまくいくではないですか。Password: に対して、暗号前のパスワード abcdefgh を入力すると、見事パスワードの認証にパスしました。

 

しかし残念ながら、これだけではまだ完璧ではないようです。

たしかに今回の場合はうまくいったのですが、パスワードの文字数を8文字未満で登録してしまうと、Slackware ではなぜか認証に失敗してしまったことがありました。今回の Red Hat Linux 7J では何の問題もなさそうでしたが、/etc/shadow を眺めてみても明らかに passwd コマンドを使って生成した暗号化済みパスワードとは雰囲気も文字列の長さも違うし・・・。

 

ということで、もっと安心して crypt が使えるように、いろいろと調べてみることにします。

 

/bin/su のソースを探る

ふとしたきっかけで、su のソースファイルに目をつけました。

su というのはユーザを入れ替えるためのコマンドで、当然ながら、途中でユーザ認証が入ってきます。ユーザ認証の時には入力された暗号化されていないパスワードを暗号化して、/etc/passwd や /etc/shadow に保存されている暗号化済みパスワードと照らし合わせるという方法を取ります。ですので、su の認証プロセスを調べれば、必然的に暗号化する方法がわかるというわけですね。

ソースファイルは、Slackware 7.1 の Source CD-ROM の中から借用してしまうことにします。a/ ディレクトリの sh_utils/ ディレクトリの中に、su.c という C 言語で書かれたプログラムがありましたので、それを /usr/src/ へコピーしてきて展開しました。

ソースファイルを追っかけるなんてめったにしないことだったので、おそるおそる、su.c を開いてみました。

なんと、なかなか手ごろな量(少なめ)のソースではありませんか。初の Linux (の一部) のソース追いかけにはちょうどよさそうな量。ちょっとほっとしました。

 

C プログラムの開始地点、main 関数から調べ始めてみることにします。とりあえず、それっぽい部分を探して、そのときに変数にどのような値がセットされているかを調べてみることにします。

"incorrect password" という文字リテラルを発見。それを判定する条件には correct_password という関数が使われていたので、おそらくこの関数内で暗号化とパスワード認証が行われているのでしょう。ということで、correct_password 関数に焦点をしぼって眺めてみることにしました。

 

ありました。最終的には最後の行で、return strcmp ( encrypted, correct ) == 0; という処理を行っています。ずばり、暗号化したパスワードと、保存されていた暗号化済みパスワードの比較が行われています。

実際に、そのあたりで使用されている変数はこんな感じのようです。

pw->pw_name 認証するユーザ名(/etc/shadow) からの暗号化済みパスワードの取得に使用する。
pw->pw_passwd /etc/passwd に記述されたパスワードが入る様子。Shadow Password の場合は、getspnam(pw->pw_name) として、spwd 構造体を取り出し、そこから sp_pwdp で取り出している様子。
correct 最終的に /etc/passwd や /etc/shadow から取り出した暗号化済みパスワードが保存される様子。
unencrypted getpass 関数を使用して、ユーザから入力されたクリアーパスワードを保存している様子。
encrypted C の crypt 関数によって unencrypted に入力されたパスワードを暗号化したものが保存される。

 

気になる点としては crypt 関数の部分。

encrypted = crypt ( unencrypted, correct );

というように、SOLT の部分に、暗号化済みパスワードを持ってきています。どうやら、クリアパスワードに、そのパスワードを暗号化したものを SOLT として与えることで、まったく同じ暗号化済みパスワードになるようです。

つまり、以前に暗号化するときに使った SOLT を知らなくても、暗号化済みパスワードと、その元のパスワードがわかれば、問題なく認証を行うことができるということですか。。。

 

さて、上記でも書いた変数の値を表示するためのプログラムを埋め込んで、make、そして出来上がった su をちょくせつ実行してみると・・・???

あれ、なぜかあっているはずのパスワードが通らなくなってしまいました。変数の値を表示する以外には何もいじっていないというのに・・・。表示させた変数をみると、correct の値として、x が・・・。これは、Shadow Password で設定されているパスワードが取得できなかったということですね・・・。

そのあたりを調べてみると、getspnam 関数を呼ぶためには HAVE_SHADOW_H という値が定義されている必要があるようです。そこで、どこでこれが宣言されるのかわからなかったので、半ば強引に #define HAVE_SHADOW_H と自分で書き込んでみることに。make してみると、HAVE_SHADOW_H が再定義されたという知らせが・・・。どうやら HAVE_SHADOW_H は宣言されているので別の問題のようですね。

もしかして、Slackware の su.c を持ってきたのが間違いだったのか・・・?

 

/etc/shadow のための権限

なぜ、うまくいかないのか。いろいろと過去の記憶をたどりつつ、ふと思いつきました。そういえば、はじめに書いたとおり、/etc/shadow は、一般ユーザが読み取れない設定になっているではありませんか。

今回の場合も、オリジナル su を実行したのは一般ユーザです。root 権限だとパスワードの入力が必要ないので・・・。

 

ということで、オリジナル su を、オーナー権限で実行できるように設定しておかないといけないのですね。root 権限から以下のようなコマンドを打ち込んで、オーナー権限で動くプログラムに仕上げます。

chmod +s src/su

これで、一般ユーザが実行されたときに、自動的に su の所有者、今回は root 権限で動くようになるそうです。

実際にもう一度、一般ユーザでオリジナル su を動かしてみると、ちゃんとパスワード認証も通るし、correct の値も、ちゃんと /etc/shadow に記述されている値を表示してくれました。

 

crypt 関数は何処?

さて、仕組みはなんとなくわかったのですが、まだまだ謎は残っています。

何気なく su が使用している crypt 関数ですが、その実体はどこにあるのでしょう・・・。どうやら関数のポインタとして crypt が宣言されているようですが、果たしてどこで実装しているのか、ぱっと見たところわかりませんでした。

 

そこで、main 関数のはじめと、実際に crypt を呼び出す直前の2箇所に、crypt の参照先を表示するプログラムを埋め込んで見ました。実際の値を見てみると、main 関数へ突入した直後から既に、crypt は実体を持っているようです。念のため、宣言直後の crypt 変数に NULL を代入してみると、コンパイラによってエラーが返されました。どうやら、宣言直後に NULL を代入しただけでも関数ではなくなってしまう様子です。

とすると、既に使える状態にあるのでしょうか。ためしに、char *crypt() の行をコメントアウトしてみます。もう一度 make してみると、なんのエラーもなくコンパイル完了です。実際にオリジナル su を動かしてみても正常に動作するようです。

 

・・・、思い出しました。

最近 C++ 等とは無縁だったため、どうも * が関数名の近くについていると、「これは関数のポインタなのかな?」 と思ってしまうのですが、どうやらプロトタイプ宣言のようですね。とすると、どこかのヘッダーファイルあたりに記述されているのでしょう。調べてみると、crypt.h 内で crypt 関数が宣言されているようでした。

 

SALT

オリジナル su でちゃんと暗号化パスワードが表示されるようになったので、今度は /etc/passwd や /etc/shadow から情報を取り出すのではなくて、ちょくせつ定数で指定してみることにしました。crypt 関数に対して 【第2節】 の時と同様のパスワードと SALT を与えてみると、なんとまた短めの暗号化パスワードができてしまうではないですか。

 

どうやら crypt 関数の使い方を間違えているようです。

今回のオリジナル su で、正常に動いていたときと今との違いは、SALT が定数で与えられたということです。さらに以前は、暗号化済みのパスワードがそのまま SALT として指定されていました。/etc/shadow の暗号化済みパスワードをみると、明らかに crypt のマニュアルによると記述できない記号 $ が使用されています。どうやらこれがかぎを握っていそうです。

マニュアルでは SALT は2文字ということなので、とりあえず、暗号化済みパスワードの先頭2文字を取り出して暗号化をしてみます。すると、間違った暗号化パスワードが導き出されてしまいました。

今度は3文字。この時点で、生成された暗号化済みパスワードの長さが、2文字の SALT の時の倍くらいの長さになりました。どうやらこの先頭の $1$ という文字がかぎを握っているようです。しかしながらまだ間違った暗号化済みパスワードが導き出されています。

この調子で1文字ずつ増やしていくと、$1$ のあとに8文字つなげた時点で、正常な暗号化パスワードが導き出されました。これ以降は、9文字にしても10文字にしても無視されるようです。

 

以上のことから、現在の crypt 関数は 3 + 8 文字の合計 11 文字で構成すればいいようです。

【第2節】 の Perl でも、このような SALT を渡してみたところ、正常な暗号化済みパスワードを入手することができました。よくよくみると、man crypt によって表示されたマニュアルの一番最後には、September 3, 1994 とかかれていました。マニュアルが古すぎたのでしょうかね・・・?

 

…と、ふとしたことからこのあたりの情報が入手できました。

この $1$ を使ったものは glibc2 で実装されたそうで、この場合は暗号化に MD5 という方式をとるようです。細かい約束事としては、$1$ から始まって続く 8 文字以内を SALT とすることが出来るようです。使用できる文字セットは2文字のときと変わりません。

SALT の終了文字は "\0" または "$" で指定することになっているようで、これによって 8 文字未満であっても暗号化済みパスワードの SALT を抽出する際にわからなくなることはないようです。

なお、従来の2文字の SALT の場合、暗号化は DES によって行われるとのことです。

 

C++ で暗号化プログラムを組んでみる

おおげさな書き出しですが、要は C++ で crypt 関数を呼び出すプログラムを作ってみようというものです。

やることは簡単で、encrypt という名前のプログラムを作成して、第1引数には PASSWORD、第2引数には SALT を渡せば、標準出力へ暗号化済みパスワードが表示されるという簡単なものです。

// C++ の標準的なヘッダ

#include <iostream.h>

#include <string.h>

 

// crypt 関数を使用するためにこのヘッダが必要

#include <crypt.h>

 

 

int main ( int argc, char* argv[] )

{

char* encrypted;    // 暗号化済みパスワード

char* unencrypted; // 平文パスワード

 

char* target;

char* salt;

 

 

if ( argc > 2 )

{

// 引数が与えられていればそれを取り出す

target = argv[1];

salt = argv[2];

}

else

{

// 引数が与えられていなければとりあえずヌル文字に

target = "\0";

salt = "\0";

}

 

// unencrypted 変数にパスワードを準備する

unencrypted = new char [ strlen ( target ) + 1 ];

strcpy ( unencrypted, target );

 

// 暗号化済みパスワードを encrypted 変数へ取得する

encrypted = crypt ( unencrypted, salt );

 

// 標準出力へ書き出す

cout << encrypted << endl;

 

// 変数の後始末

delete [] unencrypted;

 

return true;

}

上記の灰色の文字の部分は基本的にはどうでもよくって、その他の色の部分が今回の話題にそぐったものになっています。

このファイルを encrypt.cpp という名前にした場合、gcc -xc++ -o encrypt -lstdc++ -lcrypt encrypt.cpp としてコンパイルすれば、encrypt という名前のプログラムファイルが完成します。あとは、./encrypt 'abcdefgh', '$1$x2345678' とすれば、abcdefgh というパスワードの暗号化済みパスワードが画面に表示されます。

 

Perl を用いてユーザアカウントを追加する

Linux には useradd というコマンドがあります。

これを使用すればユーザアカウントをコマンドラインから作成することができるのです。さらに -p オプションとして暗号化済みパスワードを与えればパスワードも自動的に登録されるようになっています。

#!/usr/bin/perl

 

$unencrypted = 'abcefgh';

$salt = '$1$x2345678';

 

$username = 'testuser';

$encrypted = crypt ( $unencrypted, $salt );

 

system ( "useradd -p '$encrypted' $username" );

このようなプログラムを組むことで、abcdefgh というパスワードを持った testuser というアカウントが作成できました。