問題
zlib 1.2.11 (tar.gz版)をmsysのbashでビルドしようとし、configure
を走らせたらsed
が謎のエラーを吐いた。
出たエラーは、
sed.exe: -e 表現 #1, 文字数 1: 未知のコマンドです: 「C」
sed.exe: -e 表現 #1, 文字数 8: アドレスregexが終了していません
の2種類。同様のエラーは他のソフトウェアのconfigure
でも見られ、以前から気になっていた。
渡されているコマンドライン引数の調査
エラーメッセージが主張する「未知のコマンド」とはどういうことなのだろうか?
これを調べるため、configure
中のsed
を./xyz
に置換することで、以下のコマンドライン引数を出力するプログラムを実行するようにし、実行してみた。
なお、configure
の動作への影響を減らすため、標準出力ではなく標準エラー出力に出すようにしている。
# include <stdio.h>
int main(int argc, char* argv[]) {
int i;
for (i = 0; i < argc; i++) {
fprintf(stderr, "argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
すると、以下のような出力(冒頭部分)になった。
zlibのconfigure
中でこの部分に相当すると考えられるのは、42行目からの
# extract zlib version numbers from zlib.h
VER=`sed -n -e '/VERSION "/s/.*"\(.*\)".*/\1/p' < ${SRCDIR}zlib.h`
VER3=`sed -n -e '/VERSION "/s/.*"\([0-9]*\\.[0-9]*\\.[0-9]*\).*/\1/p' < ${SRCDIR}zlib.h`
VER2=`sed -n -e '/VERSION "/s/.*"\([0-9]*\\.[0-9]*\)\\..*/\1/p' < ${SRCDIR}zlib.h`
VER1=`sed -n -e '/VERSION "/s/.*"\([0-9]*\)\\..*/\1/p' < ${SRCDIR}zlib.h`
であり、なぜか引数にC:/MyApps/MinGW/msys/1.0
という余計なプリフィックスがついてしまっていることがわかった。
すなわち、なんとかしてこのプリフィックスが付かない状態の引数をsed
に渡せれば、「未知のコマンドです」エラーは潰せそうである。
プリフィックス対策のラッパの作成
余計なプリフィックスによりsed
の動作が妨害されるのをさけるため、ラッパを開発した。
まずは小さいケースで試し、sed
のかわりに先ほどの引数表示プログラムxyz
に引数を渡す。
ラッパを使わないと、このように余計なプリフィックスがついてしまった。
bash-3.1$ xyz "/abc/i def"
argv[0] = c:\Temp\xyz.exe
argv[1] = C:/MyApps/MinGW/msys/1.0/abc/i def
C言語での作成(失敗)
# include <stdio.h>
# include <stdlib.h>
# include <string.h>
# include <unistd.h>
int main(int argc, char* argv[]) {
static const char* TARGET = "xyz.exe";
static const char* TRASH = "C:/MyApps/MinGW/msys/1.0";
const char** args = malloc(sizeof(*args) * (argc + 1));
int i;
size_t tlen = strlen(TRASH);
if (args == NULL) {
perror("wrapper: malloc");
return 1;
}
args[0] = TARGET;
for (i = 1; i < argc; i++) {
args[i] = argv[i];
if (strncmp(args[i], TRASH, tlen) == 0) args[i] += tlen;
}
args[argc] = NULL;
execvp(args[0], args);
perror("execvp");
return 1;
}
渡されたコマンドライン引数中に例のプリフィックスがあれば、削除するという削除するプログラムである。
このプログラムを用いると、以下のような実行結果になった。
bash-3.1$ wrapper "/abc/i def"
bash-3.1$ argv[0] = xyz.exe
argv[1] = /abc/i
argv[2] = def
プリフィックスが渡されるのは回避できているが、スペースを含む引数が別々の引数に分解されてしまっており、別の問題を引き起こすことが予想できる。
execvp
ではなくCreateProcess
でプログラムを起動することも考えたが、引数を適切にエスケープして渡すのはめんどくさそうである。
この方法は諦めた。
Perlでの作成
まずは仕様を確かめるため、以下のスクリプトを実行してみる。
# !/usr/bin/perl
use strict;
use warnings;
my @prg = ("xyz");
for (my $i = 0; $i < @ARGV; $i++) {
printf STDERR "Perl: ARGV[%d] = %s\n", $i, $ARGV[$i];
push(@prg, $ARGV[$i]);
}
exec { $prg[0] } @prg;
die "exec falied\n";
実行結果は、以下のようになった。
bash-3.1$ perl_test.pl "/abc/i def"
Perl: ARGV[0] = /abc/i def
argv[0] = c:\Temp\xyz.exe
argv[1] = C:/MyApps/MinGW/msys/1.0/abc/i def
このことから、Perlが起動されるときにはプリフィックスがつかないが、Perlからexecする時にはプリフィックスがついてしまうようである。
そこで、「/
で始まらない引数にはプリフィックスがつかないようである」という性質を利用し、/
で始まる引数の最初にsed
の動作に影響を与えなそうな空白を追加するラッパを作成した。
# !/usr/bin/perl
use strict;
use warnings;
my @prg = ("xyz");
for (my $i = 0; $i < @ARGV; $i++) {
my $arg = $ARGV[$i];
if (substr($arg, 0, 1) eq "/") { $arg = " " . $arg; }
push(@prg, $arg);
}
exec { $prg[0] } @prg;
die "exec falied\n";
実行結果は
bash-3.1$ perl_wrapper.pl "/abc/i def"
argv[0] = c:\Temp\xyz.exe
argv[1] = /abc/i def
となり、うまくいきそうである。
Rubyでの作成
Perlと同様に、まずは仕様を確かめる。
# !ruby
ARGV.each_with_index do |arg, i|
warn "Ruby: ARGV[#{i}] = #{arg}"
end
exec(*(["xyz"] + ARGV))
(参考:Rubyでコマンドライン引数を取得する方法:ARGV | UX MILK)
実行結果は
bash-3.1$ ruby_test.rb "/abc/i def"
Ruby: ARGV[0] = C:/MyApps/MinGW/msys/1.0/abc/i def
argv[0] = xyz
argv[1] = C:/MyApps/MinGW/msys/1.0/abc/i def
となり、Perlの時と違ってRubyが起動される時にすでにプリフィックスがついてしまっている。
また、
# !ruby
exec(*["xyz", "/abc/i def"])
というプログラムを実行すると
bash-3.1$ ruby_test2.rb
argv[0] = xyz
argv[1] = /abc/i def
となり、Rubyからexecする時にはプリフィックスはつかないようであった。
そこで、付けられたプリフィックスを外して起動するラッパを作成した。
# !ruby
trash = 'C:/MyApps/MinGW/msys/1.0'
tlen = trash.length
prg = ['xyz']
ARGV.each do |param_raw|
param = param_raw
if param[0, tlen] == trash then param = param[tlen, param.length] end
prg << param
end
exec(*prg)
実行結果は
bash-3.1$ ruby_wrapper.rb "/abc/i def"
argv[0] = xyz
argv[1] = /abc/i def
となり、うまくいっているようである。
もうひとつのエラー
ラッパーをテスト用のxyz
ではなくこの環境のsed
をフルパスで起動するように設定し、sed
にリネームし、このラッパー「sed
」があるディレクトリをPATH
の先頭に追加した状態でzlibのconfigure
を起動した。
(フルパスで指定しないと、本来のsed
ではなくラッパーを起動しようとしてしまい、うまくいかないことが考えられる)
その結果、「未知のコマンドです」は出なくなったが、「アドレスregexが終了していません」は出てしまい、Makefile
が空で正常なビルドができない状態であった。
調べてみると、先頭にスラッシュはないが、スラッシュなどの記号を含む長い引数が、プリフィックスが付くタイミングで途中で切られてしまうようであった。
プリフィックスが付くだけなら消せばいいが、消えてしまった情報を復元するのは不可能であると考えられる。
さて、その対処法は…
合体技だ!
ここで、先ほど調査したPerlとRubyの性質を思い出す。
- Perlは起動される時は攻撃されないが、execする時に攻撃されてしまう
- Rubyは起動される時に攻撃されてしまうが、execする時は攻撃されない
(ここで「攻撃」とは、これまで紹介してきたプリフィックスを付けたり途中で切ったりする変換を指す)
ということで、
bash
↓起動 (攻撃されない)
Perl:コマンドライン引数を攻撃に耐えられる形式に変換する
↓起動 (攻撃されるけど対策したので平気)
Ruby:コマンドライン引数をデコードする
↓起動 (攻撃されない)
sed
とすれば、攻撃の影響を避けてsed
を起動することができるでしょう。
では、「攻撃に耐えられる形式」とは具体的にどんな形式でしょうか?
スラッシュなどの記号があると攻撃されやすそうな気がしたので、文字を文字コードで16進数に変換してみた。
# !/usr/bin/perl
use strict;
use warnings;
my $TARGET = "C:\\MyApps\\GnuWin32\\bin\\sed.exe";
my $RUBY_SCRIPT = <<EOS;
exec(*ARGV.map do |param|
param.each_char.each_slice(2).map { |a| a.join.hex.chr }.join
end)
EOS
my @prg = ("ruby", "-e", $RUBY_SCRIPT, &encode_arg($TARGET));
for (my $i = 0; $i < @ARGV; $i++) {
push(@prg, &encode_arg($ARGV[$i]));
}
exec { $prg[0] } @prg;
die "exec failed\n";
sub encode_arg {
my @data = unpack("C*", $_[0]);
my $ret = "";
for (my $i = 0; $i < @data; $i++) {
$ret .= sprintf("%02X", $data[$i]);
}
return $ret;
}
(参考:Rubyで文字列をn文字ごとに区切って配列に格納する - ぬいぐるみライフ?)
このスクリプトを先ほどのラッパーと同様にPATH
の先頭のディレクトリにsed
という名前で置いてzlibのconfigure
を実行すると、無事エラーが出ず終了した。
続いてmake
を行った結果、共有ライブラリのビルドでcannot find -lc
というエラーが出てしまったが、libz.a
は生成されており、それを用いてアプリケーションをビルドすることもできた。
考察
そもそも、どうしてPerlとRubyで攻撃のタイミングに差が出たのか。
それは、一般に「msys内のプログラムからmsys外のプログラムを実行する時に変換をかける」からであると考えられる。
変換内容から察するに、そもそもこのプリフィックスを付ける動作は本来「攻撃」ではなく、互換性のために「ルートディレクトリ」からの絶対パスを実際のファイルシステム上のパスに変換する処理であると考えられる。それが、スラッシュで始まるsed
の変換コマンドに対しても誤爆してしまったのだろう。
(ただし、長い引数が切られる仕様については、明らかに情報を破壊しており、これでは説明が付かない)
この環境におけるPerlとRubyの実体を調べると、
bash-3.1$ where perl
c:\MyApps\Perl64\bin\perl.exe
C:\MyApps\MinGW\msys\1.0\bin\perl.exe
bash-3.1$ where ruby
c:\MyApps\ruby-2.2.2-x64-mingw32\bin\ruby.exe
となった。すなわち、Perlはmsys内のものとmsys外のものがあり、Rubyはmsys外のみである。
bashからmsys内のPerlを呼び出す時は変換がかからず、msys内のPerlからmsys外のRuby、sed、テストプログラムを呼び出す時は変換がかかるということだろう、また、msys外のプログラムからmsys外のプログラムを実行するときも、当然変換はかからないと考えられる。
それでは、PATH
を見るとmsys外のPerlの方がmsys内のPerlより先に来ているのに、なぜmsys内のPerlが使われたか。
それは、bashからPerlスクリプトを直接実行したため、スクリプト先頭の#!/usr/bin/perl
によりmsys内のPerlが選択されたからであると考えられる。
実際、perl
コマンドを用いてテストスクリプトを実行するとPerlが呼び出される時に変換がかかり、/usr/bin/perl
コマンドを用いてテストスクリプトを実行するとPerlが呼び出される時には変換がかからないことが確認できた。
bash-3.1$ perl ./perl_test.pl "/abc/i def"
Perl: ARGV[0] = C:/MyApps/MinGW/msys/1.0/abc/i def
bash-3.1$ argv[0] = xyz
argv[1] = C:/MyApps/MinGW/msys/1.0/abc/i
argv[2] = def
bash-3.1$ /usr/bin/perl ./perl_test.pl "/abc/i def"
Perl: ARGV[0] = /abc/i def
argv[0] = c:\Temp\xyz.exe
argv[1] = C:/MyApps/MinGW/msys/1.0/abc/i def
Rubyについてはmsys外のものしかなく、#!/usr/bin/ruby
と書くと/usr/bin/ruby^M: bad interpreter: No such file or directory
というエラーになったため、先頭は#!ruby
とした。
ついでに、msys内のsed
を明示的に実行すると、ラッパを使わなくても「未知のコマンドです」エラーが出ずに正常に処理がされることが確認できた。
bash-3.1$ where sed
c:\MyApps\GnuWin32\bin\sed.exe
c:\MyApps\w32tex_20150125\bin\sed.exe
C:\MyApps\MinGW\msys\1.0\bin\sed.exe
bash-3.1$ echo abc | sed "/abc/i def"
sed.exe: -e 表現 #1, 文字数 1: 未知のコマンドです: 「C」
bash-3.1$ echo abc | /c/MyApps/MinGW/msys/1.0/bin/sed "/abc/i def"
def
abc
結論
/
から始まる仮想パスをmsys外のプログラムに渡す時にファイルシステム上の実際のパスに変換する機能がsed
の変換指示に対し誤爆し、ついでになぜか記号が入った長い引数に対し途中で切る破壊工作が行われる問題に対し、msys内のPerlとmsys外のRubyを用いて引数を破壊工作されないと考えられる英数字だけの形式にエンコードしてmsys外に出ることで対処を行った。