Not only is the Internet dead, it's starting to smell really bad.:2019年05月下旬

2019/05/20(Mon)

[Windows] PowerShellで生活するために - Import-Csvコマンドレット編(その1)

前回はForEach-Objectコマンドレットの正体はawk(1)であると看破したけど(おい)、PowerShellはMicrosoftで生まれました、ベル研でもCSRGでもMITの発明品じゃありません、Jeffrey Snoverフェローのオリジナルです、しばし遅れをとりましたが今や巻き返しの時です。

しかし実際のところ世間一般でawk(1)の代替はImport-Csvコマンドレットの方だと思われているかもね、ただawk(1)ってやつは区切り文字でテキストを分割しての処理に適してるとはいうけど、CSVってやつはもうちょっと複雑なデータ形式でクオート文字の中に区切り文字や改行コードまで含めることが可能だから、本当はawk(1)向きではないのだ。

ワイはそもそもshebang含めて3行超えそうならPerlで書いてしまうおじさんなので *1、Text::CSV_XSあたりCPANから拾ってささっと書いとったのだが、世の中にはGNU awkの拡張(FPATとか)を駆使して頑張る人もいるらしい。

いっぽうImport-Csvはこのようなクオート中の区切り文字や改行コードも、どうぞ回してみてください…いい音でしょう?余裕の処理だ、馬力が違いますよ。

とりあえず書き方のサンプルはこんな感じ。

Import-Csv -Delimiter '`t' -Encoding OEM -Path '1.tsv', '2.tsv', ... | ForEach-Object { Write-Host $_.カラム名 }

うん、Import-CsvそのものはCSV形式のファイルをオブジェクトに変換してパイプラインに流すだけだからやっぱりForEach-Objectとセットでawk(1)やね、Import-Csvは特殊なcat(1)相当でしかないっすわ。

そんでこのプロパティ名って何を元に決めてるのかというと、1行目がヘッダ行として扱われそこでの値が使われるのですよな。

"アクセスURL","参照元URL","アクセス日時","ホスト名","User-Agent"
"/","","2019/05/01 00:01:16 JST","host1-113-0-203.example.com","NCSA_Mosaic/2.0"
...

なんてCSVファイルであれば

Import-Csv -Path 'access.log' | ForEach-Object { Write-Host $_.ホスト名 }

と書けば特定の列、このコード例なら「ホスト名」にアクセスできるわけ、またの名を源氏名。

これがもしヘッダ行の無いCSVなら(そっちの方が多いよね…)

Import-Csv -Path 'access.log' -Header "アクセスURL","参照元","アクセス日時","ホスト名","User-Agent" | ForEach-Object { Write-Host $_.ホスト名 }

のようにいちいち-Headerオプションで明示的に指定する必要があるのがちょっとめんどくせえなという感じ。

そんでこれは文法の話に脱線しちゃうけど、カラム名に「-」や「$」などの特殊文字やスペース等が含まれる場合

$_.{User-Agent}
$_.'User-Agent'
$_."User-Agent"

とクオートしてやらんと演算子や変数と間違われて予期しない結果になるのは注意。

なおSelect-Objectの-Propertyオプションの引数ではクオートしなくてよいとか

$_ | Select-Object -Property User-Agent

なーんか一貫性が無いよな…

はいお次、最後の列だけ取り出すにもawkなら組込変数NFを使えばいいのだけれどもPowerShellではちょいと厄介だ。 ヘッダを明示的に指定しているパターンであれば、配列の最後の要素は添字に-1を指定することで取り出せるので

$header = "アクセスURL","参照元","アクセス日時","ホスト名","ユーザーエージェント"
Import-Csv -Path 'access.log' -Header $header | ForEach-Object { Write-Host $_.($header[-1]) }

とすることで何とかなるのだけど、CSVファイル中のヘッダ行を使う場合には

Import-Csv -Path 'access.log' | ForEach-Object { $_.($_.PSObject.Properties.Name[-1]) }

とだいぶ薄汚れたコードを書かないとあかんっぽい、なんやこのクソ言語…

@次回

まだまだImpotent-Csvへの文句は続くよ…

*1:なおPowerShellと同様に記憶喪失かと思うくらい覚えられないのだよね>Perl、何年書いてるんだっけ俺…毎回同じような事を調べてる気がする…

2019/05/24(Fri)

[Windows] Dismコマンドで永続的パッケージを無理矢理アンインストールする

今家族マシン用に確保してあるWindows 8のDSPライセンスだけど、8をインストールしてパッチ適用してストア経由で8.1にアプグレしてからまたパッチ適用というアホみたいな手順は踏みたくない。 しかしWindows 8.1はサービスパックみたいに単体でWindows 8のインストールイメージに統合できる形式で提供されてないのでアレ。

なのでせっかくMicrosoft公式が

を提供してるので、こいつ使ってクリーンインストールすることにする。 なんせWindows 8のシリアル番号はそのままWindows 8.1のインストールに有効だから利用条件は満たしてるので以下略

ちなみにISOの中身はRTM版ではなくUpdate 3なんだけどそれでも150近いパッチの適用が必要で、Security Rollup適用すれば70程度で済むWindows 7よりひどいことになっている、うーんこの。

そしてしれっと悪名高いKB2976978いわゆるテレメトリパッチが混入しているのがクソ、ワイはパラノイアなので

dism /Image:mount /Remove-Package /PackageName:Package_for_KB2976978~31bf3856ad364e35~amd64~~6.3.2.1

を実行してアンインストールを試みたんだけど

1 / 1 を処理しています - Package_for_KB2976978: 永続的パッケージはアンインストールできません。
 エラー: 0x800f0825

エラー: 0x800f0825

DISM が失敗しました。操作は実行されませんでした。

というエラーが出てアンインストールできねえやんけクソが。

これはなぜかというとシステム回復時にUpdate3の更新ロールアップまでリセットされないように

dism /Image:mount /Cleanup-Image /StartComponentCleanup /ResetBase

を実行してそれらのパッチ(テレメトリまで含めて)に永続化をマークしてるからなのよね、詳しいことは これ読め

ところがこの永続化フラグってのは実にいいかげんなものなようで、もういちどAdd-Packageし直したら外れることが判った。

dism /Image:mount /Add-Package /PackageName:Package_for_KB2976978~31bf3856ad364e35~amd64~~6.3.2.1
dism /Image:mount /Remove-Package /PackageName:Package_for_KB2976978~31bf3856ad364e35~amd64~~6.3.2.1

ポイントはPackagePathではなくPackageNameにインストール済のパッケージ名を指定してAdd-Packageを実行するのがミソ。 もちろんこのKB2976978がベースシステムの置き換えで無い単純な増分でしかないから可能な技で、Cleanup-ImageでWinSxSの下にある古いバージョンは消されちゃった永続的パッケージは削除できないとは思うが。

そんでPowerShellのAdd-WindowsPackageコマンドレットは-PackageNameオプションが無いけれどそれなら-PackagePathを使えばいいじゃないと、最新のKB2976978(今はv24)のパッチをダウンロードしてきて

Add-WindowsPackage -Path .\mount -PackagePath .\windows8.1-kb2976978-v24-x64_edb8f26452e645838dc6797fa23374fb24cfd2df.msu
Get-WindowsPackage -Path .\mount | Where-Object { $_.PackageName -match 'KB2976978' } | ForEach-Object {
	Remove-WindowsPackage -Path .\mount -PackageName $_.PackageName
}

とすることでこっちでも削除できたゾ。

2019/05/25(Sat)

[Windows] PowerShellで生活するために - Import-Csvコマンドレット編(その2)

前回はImport-Csvというのは awk(1)というより、CSVファイルを読込んでオブジェクト(=PSCustomObject)のストリームとして流す特殊な cat(1)だと書いた。

なお特殊ではないcat(1)に最も近いコマンドレットには Get-Contentというのがある。

Get-Content -Path 'unko.csv' -Encoding OEM | ForEach-Object { Write-Host $_ }

こちらはファイルをやはりオブジェクト、つまりString *1に変換してパイプに流す。

細かいことをいえばcat(1)が「con CAT inate(結合)」であるのに対し「Get Content(中身をとりだす)」という思想の違いがあるけど、そこは目を瞑っておこう。 そもそもcat(1)でファイル結合ができた時代なんてのは遠い過去の歴史上の話だ、CSVみたいな古色蒼然としたファイルフォーマットですらヘッダ行が存在するなら結合時に読み飛ばさんとおかしなことになる。

なによりテキストファイルの文字コードがUS-ASCIIしかない時代ならまだしも、現代では数えきれないほどの文字コードが存在し異なる文字コードのファイルをcat(1)したら即文字化けだ。 Unicodeですら複数CESが存在してあまつさえBOMなんてシロモノがある時点で論外なんやで。

ということでファイルフォーマットの数だけcat(1)が増えるのは致し方ない、三毛とか鯖虎とかね…

@UNIX哲学の負の側面

そんでcat(1)というと思い出すのが Useless Use Of Cat、つまり「無駄にネコさまの手をわずらわせる」と呼ばれる性能問題なんですわ。

簡単な例としては

$ cat unko1.txt unko2.txt | awk -F',' '{ print $NF }'

というやつ、awk(1)では

$ awk -F',' '{ print $NF }' unko1.txt unko2.txt

と引数でファイルを指定できるので、ここでのネコさま登場させるのはパーフェクトに無駄骨でありプロセス生成とプロセス間通信のコストだけ性能は確実に劣化するわけ。

そんでこちらもまったく意味の無いファイルの最後5行を表示するのにネコの尻尾踏んづけるコード

$ cat unko.txt | tail -n 5

尻尾ひとふりするだけで終わる仕事なんよな。

$ tail -n 5 unko.txt

他にも

$ cat unko.txt | sort | uniq

なんて書かずに

$ sort -u unko.txt

とsort(1)だけで書けるよとかね。

この問題はcat(1)にとどまらないので、はつみみですというネコは米Yahoo!でスケーラビリティーに関する仕事に携わってた、NなんとかBSDの開発者による「 Useless Use of *」というプレゼンでも読んでみてどうぞ。

そんでよくUNIX哲学と関数型言語は似ているなんていわれるけど、それはすなわち同じ欠点を持ってるってことなのだ。Useless Use Of Higher-Order Functionとでも呼ぶんすかねこれ。

これはPowerShellも同じで

Get-Content unko.txt | Select-Object -First 5 | ForEach-Object {
	Write-Host $_
}

なんてコードを書かずに

  • -TotalCount … 実質head(1)コマンド
  • -Tail … 実質tail(1)コマンド

とSelect-Objectの-First/-Lastオプションに相当するfilterが実装されてるのでそちらを使った方が性能的に有利なはず。

Get-Content -TotalCount 5 | ForEach-Object {
	Write-Host $_
}

同じ理屈はそのまんまImport-Csvにも当てはまるんだけど、こっちには読込む行数を指定するオプションが存在しないのよね、あくまでパイプで渡した先のfilter側で件数を絞らざるをえない。

Import-Csv 'unko.csv' -Header "Column1", "Column2" | Select-Object -First 5 | ForEach-Object {
	Write-Host $_.Column1
}

まぁパイプで繋いでるのだから

  • Select-Objectがパイプラインから5レコード読んだら
  • SIGPIPEだかバグパイプ的なものがピーヒャララと飛んで
  • それを受取ったImport-Csvは読込処理をそこで終了

するだろうからCSVファイル全体を読み込むわけでもなし、そもそもコマンドレットはプロセスではないからUNIXシェルスクリプトよかコストは格段に低いとは思うけど。

ただ 過去回でさらっと触れたforeach vs ForEach-Object対決をみる限り、ほんとうに無視できるほどのコストなんですかね?って疑ってしまうのですな。

ちなみに性能測定にはMeasure-Commandというコマンドレットがあるけど、困ったことにImport-CsvはCSVファイルをパイプラインから読み込むという動作ができんので

Measure-Command {
	Get-Content 'unko.txt' | Import-Csv | Select-Object -First 5
}

Measure-Command {
	Get-Content -TotalCount 5 'unko.txt' | Import-Csv
}

の差を測定するみたいな性能テストができないのよね、PowerShellはオープンソースなので ソース落としてきて読み込む行数を指定するオプションを自分で実装してどれくらい改善するか調べりゃいいんだが、あまり読みたいコードじゃねぇんだよなぁ…

@パイプを流れるストリームがテキストではなくオブジェクトであることの弊害

そしてここでもうひとつImport-Csvの、ひいてはPowerShellそのもののバッドデザインが明らかになりましたね、そうImport-CsvはパイプラインからCSVファイルを読み込めないんですわ。

例えばcat(1)はパイプからもデータを読み込める、だから下のような完全に意味の無い多頭飼いネコの数珠繋ぎができる、これにはムカデ人間のヨーゼフ・ハイター博士もニッコリ。

$ cat - | cat | cat | cat | cat | cat | cat | cat | cat | cat | cat

まぁこの節操の無さこそがUseless Use Of *問題を生むんだけどね!

しかしPowerShellにおいてGet-ContentやImport-Csvのようなコマンドレットは、ファイルをオブジェクトに変換してパイプにストリームとして流す起点にはなれるんだけど、自身がパイプからストリームを読み込むができないんですわ。

これすげー地味に利便性悪く、インターネットから例えば「非国民の休日.csv」なんてものをダウンロードしてきて処理するのに

[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
(Invoke-WebRequest -Method Get -Uri 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv').RawContent `
    | Import-Csv -Encoding OEM |  Where-Object { ([DateTime]$_.{国民の祝日・休日月日}).Year -eq 2019 } | ForEach-Object {
	Write-Host $_.{国民の祝日・休日月日}
}

みたいに書けないのよね、いちいち一時ファイルとして保存して

[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$tmpfile = New-TemporaryFile
Invoke-WebRequest -Method Get -Uri 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv' -OutFile $tmpfile
Import-Csv -Encoding OEM $tmpfile | Where-Object { ([DateTime]$_.{国民の祝日・休日月日}).Year -eq 2019 } | ForEach-Object {
	Write-Host $_.{国民の祝日・休日月日}
}
Remove-Item -Force $tmpfile

と余計な処理が増えるのがいろいろ制約でてきてつらい。

UNIX哲学においてはパイプを流れるのはテキストという汎用インタフェースなのだけど、PowerShellにおいてはオブジェクトなのでImport-CsvもCSVファイルを読込んでせっせとオブジェクトを作ってパイプに流すけど 便所に紙以外のもの流すと詰まるよなーという話、Microsoftのオフィスのトイレはベル研より配管が太いのだろう、まぁベル研は音響カプラで300ボーという便所だったしな…

そういえばGoogle翻訳は「トイレ スッポン」を「Toilet Suppon」と訳すので、こっちもさぞや詰まらない立派な便所があるんだろう。 ちなみに正解は「Toilet Plunger」、でも「Toilet Softshell Turtle」と訳されて動物愛護団体と深刻な文化摩擦を引き起こされりよかマシか…。 なお「すっぽん」だとなぜか「Happy」と訳されるので、Googleオフィスはみな裸族であり川とか流れててそこで用を足してると推察される。

@次回

もうちょっとだけImport-Csvへの文句は続く、そして今度はCSVをファイルに出力するExport-Csvの話までいけたらいいね。

*1:あるいは-AsByteStreamオプションでByte、6.0以降より

2019/05/26(Sun)

[Windows] PowerShellで生活するために - New-TemporaryFileコマンドレット編(その1)

ちょいと前回のサンプルにNew-TemporaryFileコマンドレットが登場したので、Import-Csv/Export-Csvの話より先に書いておく。

@UNIXのmktemp(1)とは何か

毎回WindowsカテゴリなのにタッパーウェアことUNIXの話をしている気がするけどまあええわ、タッパーウェアにおいて一時用ディレクトリである/tmpや/var/tmpは誰でも読み書きが可能な「激アツスリーセブン!ジャンバリ大開放!!」パーミッションとなってる。

$ ls -ld /tmp
drwxrwxrwt+ 1 tnozaki None 0 May 25 23:47 /tmp

$ ls -ld /var/tmp
drwxrwxrwt+ 1 tnozaki None 0 Apr 14 10:26 /var/tmp

今時Cygwinなのでオーナーとグループが変だが気にするな、ここに作業用のファイルやディレクトリを作成する場合、情報漏洩やシンボリックリンク攻撃などのセキュリティ事故を避けるよう注意してコーディングする必要がある。

まぁ詳しい話はここはタッパーウェア通販サイトでもパチスロ情報誌でもないので セキュアコーディングガイドでも読んどけ、 最近のOSは「per user tmp」すなわちユーザー毎に一時ディレクトリが用意されているのでセキュリティについては昔よりマシにはなってるけど、機密性がわずかにマシになっただけでプログラミングには変わらず注意が必要なことに変わりはない。

シェルからならたいていの環境にはmktemp(1)コマンドが用意されているので、これを使って作業用ファイル・ディレクトリを作成すればおk。

作業用ファイルなら

$ f=`mktemp -p /tmp unko.XXXXXX`
$ ls -l $f
-rw------- 1 tnozaki None 0 May 25 23:46 /tmp/unko.CrhtWx
$ rm $f

作業用ディレクトリであれば

$ d=`mktemp -d -p /tmp unko.XXXXXX`
$ ls -dl $d
drwx------+ 1 tnozaki None 0 May 25 23:47 /tmp/unko.ygEwOO
$ rmdir $d

どちらのケースでも

  • XXXXXXの部分が乱数を元に生成された英数字62種に置換えられてるので、62^6通りのファイル名が生成 → 予測不可能なファイル名
  • パーミッションもファイルなら0600、ディレクトリなら0700で他のユーザーからは読み書きできない → 適切なアクセスコントロール
  • 作成したファイル・ディレクトリは既に存在したものを上書きしてないことが保証される → 競合状態の回避

というさっきのセキュアコーディングガイドで触れられている原則が、このコマンドを使うだけで保証できる訳。

ちなみにmktemp(1)の内部ではC APIである

  • mkstemp(3) … 作業用ファイル作成
  • mkdtemp(3) … 作業用ディレクトリ作成

が呼ばれているはずだ、以下はオレオレN6のコードより。

134                 if (dflag) {
135                         if (mkdtemp(name) == NULL) {
...
144                 } else {
145                         fd = mkstemp(name);
...
156                 }

なおコマンドと同名のmktemp(3)は設計ミスで危険な関数なので決して使ってはならない、POSIX:2008でめでたく抹殺されました。

ちなみにSolarisとかAIXなんかの商用UNIXしか経験の無いシェルスクリプトコーダーには、わりと最近までmktemp(1)が存在しなかった関係上「プログラム名 + プロセスID + 日付時刻」みたいなザルなコード書く輩が多いという印象がある。

$ f=`date +"/tmp/$0-$$-%Y-%m-%d_%H:%M:%S"`
$ touch $f
$ ls -l $f
-rw-r--r-- 1 tnozaki None 0 5月  26 00:55 /tmp/unko.sh-1362-2019-05-26_00:55:57
$ rm $f

こーゆーのみかけたら見次第殺。

だからshebang含めて3行以上のシェルスクリプトは書きたくない読みたくないのだよ心臓が止まりそうになる、なぜこれじゃダメなのかはもうお判りですね?

@PowerShellにおけるmktemp(1)の代替品

これはPowerShellも同様の無関心さだったようで、mktemp(1)に該当するコマンドレットであるNew-TemporaryFileは5.0になってようやく実装された始末。

$tmpfile = New-TemporaryFile

それ以前の環境であれば、.Net FrameworkのSystem.IO.Path::GetTempFileNameを使うしかない。

$tmpfile = [System.IO.Path]::GetTempFileName()

ちなみにNew-TemporaryFileの実装( Microsoft.PowerShell.Commands.NewTemporaryFileCommand)もGetTempFileNameを呼出してるだけなのだ。

 16     public class NewTemporaryFileCommand : Cmdlet
 17     {
...
 29                     filePath = Path.GetTempFileName();
...

そして.Net Frameworkの System.IO.Pathのコードを読むと

166        public static string GetTempFileName()
167        {
...
176            uint result = Interop.Kernel32.GetTempFileNameW(
177                ref tempPathBuilder.GetPinnableReference(), "tmp", 0, ref builder.GetPinnableReference());

と、Windows APIのFileAPI.hにある GetTempFileNameWを呼んでるだけなので、詳しい挙動はそっちを参照ってことですな。

しかし困ったことに作業用ファイルの作成はこいつら使えばいいんだけど、作業用ディレクトリを作成する方法については未だにPowerShellにも.Net FrameworkにもWindows APIにもご用意されていないのだ、ファッキン。

@よくあるまちがい

ちなみに作業用ディレクトリを掘れといわれてワンワンワン、セキュリティに理解の浅いプログラマー未満のやらかしがちな失敗は

  • System.IO.Path::GetRandomFileNameを使ってランダムな名前で作業用ディレクトリを掘ればいい
    $tmpdir = [System.IO.Path]::GetRandomFileName()
    New-Item -ItemType Directory $tmpdir
    
  • 作業用ファイルを作成した後に削除し再び同名で作業用ディレクトリを掘ればいい
    $tmpdir = New-TemporaryFile
    Remove-Item $tmpdir
    New-Item -ItemType Directory $tmpdir
    

みたいなコードを書いてしまいがちなんだけど、これどちらもNew-Itemするまでのわずかな時間に「TOCTTOU(Time Of Check To Time Of Use)」と呼ばれる競合状態が発生する可能性があるのでアウトなのだ、TOCTTOUについては 過去回で説明を書いてるので今回は省略。

@本当にそのコードって安全?

んで話脱線するけども、前回Import-Csvがパイプからファイル読めないから作業用ファイルを経由するというサンプル書いたけど

[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$tmpfile = New-TemporaryFile
Invoke-WebRequest -Method Get -Uri 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv' -OutFile $tmpfile
Import-Csv -Encoding OEM $tmpfile | Where-Object { ([DateTime]$_.{国民の祝日・休日月日}).Year -eq 2019 } | ForEach-Object {
	Write-Host $_.{国民の祝日・休日月日}
}
Remove-Item -Force $tmpfile

これだってパラノイアこじらせると怪しいコードに見えてくるのだ、Invoke-WebRequestの-OutFileの挙動がリダイレクトの「>」つまり上書き相当なら問題ないけれど、これが「削除 → 新規作成」という内部動作しとったらやはりTOCTTOUが発生する可能性がある。

なので安心したいので念の為に仕様を確認しておこう、PowerShellのコア部分はMITライセンス下のオープンソースなのでさっさとソースを読むことにする。

  • Microsoft.PowerShell.Commands.InvokeWebRequestCommand
    16     public class InvokeWebRequestCommand : WebRequestPSCmdlet
    17     {
    ...
    32         internal override void ProcessResponse(HttpResponseMessage response)
    33         {
    ...
    52             if (ShouldSaveToOutFile)
    53             {
    54                 StreamHelper.SaveStreamToFile(responseStream, QualifiedOutFile, this);
    55             }
    
  • Microsoft.PowerShell.Commands.WebRequestPSCmdlet
     87     public abstract partial class WebRequestPSCmdlet : PSCmdlet
     88     {
    ...
    369         [Parameter]
    370         public virtual string OutFile { get; set; }
    ...
    
    683         internal bool ShouldSaveToOutFile
    684         {
    685             get { return (!string.IsNullOrEmpty(OutFile)); }
    686         }
    ...
    
  • Microsoft.PowerShell.Commands.StreamHelper
    261     internal static class StreamHelper
    262     {
    ...
    326         internal static void SaveStreamToFile(Stream stream, string filePath, PSCmdlet cmdlet)
    327         {
    ...
    338                 using (FileStream output = File.Create(filePath))
    339                 {
    340                     WriteToStream(stream, output, cmdlet);
    341                 }
    ...
    

このあたりのコードをざっと読むと

  • Invoke-WebRequestコマンドレットの実体はInvokeWebRequestCommandクラスである
  • InvokeWebRequestCommandクラスはWebRequestPSCmdltクラスを継承してる
  • Invoke-WebRequestの-OutFileオプションに指定された引数はWebRequestPSCmdltクラスのOutFileプロパティに格納されている
  • OutFileプロパティが空でない場合、StreamHelperクラスのSaveStreamToFileメソッドが呼ばれる
  • SaveStreamToFileメソッドの中ではSystem.IO.File::Createメソッドが呼ばれる
  • Createメソッドの仕様としてはすでにファイルが存在する場合は上書きモードになる

ということなのでTOCTTOU問題は回避できるので一安心ですな。

だいぶ脱線した、これもストリームに流れるのがテキストでなくオブジェクトなので迂闊にリダイレクトが使えないPowerShellのクソデザインがそもそも悪手なのだ、テキストなら「>」か「>>」使えれば一目瞭然なんだよな、だから 最初に門倉元投手の言葉を借りて「PowerShellだけはやめとけよ」といいたくなるのもお分かりいただけるだろうか。

@次回

作業用ディレクトリを作成する方法なんだけどこれはもう自分で実装するより他にないのだ、セキュリティのためには少なくとも

  • 予測不可能なファイル名
  • 適切なパーミッションを設定する
  • 既に存在するディレクトリとは絶対に被ってはならない

は必須になるのだけれど、これをどう実現したものか説明しようと思う。

2019/05/27(Mon)

[Windows] PowerShellで生活するために - New-TemporaryFileコマンドレット編(その2)

前回はNew-TemporaryFileコマンドレットを紹介し、これで一時ディレクトリに作業用ファイルは作れるけど作業用ディレクトリは掘れないやんけ!ってお話。

無いならNew-TemporaryDirectoryを作ればいいじゃない、でもどうやってセキュリティを担保すればいい?

@予測不能な名前でディレクトリを作成する

なんか難しそうだなーと思うかもしれないけど実はそう大したもんじゃない。

予測可能な名前というのは

  • 連番
    workdir_0001
    workdir_0002
    ...
    
  • 現在時刻
    workdir_2019-05-24-00:00:00:000
    workdir_2019-05-25-23:59:59:999
    …
    

みたいな攻撃者が容易に次に作成されるファイルの名前を、金曜日の海軍の夕食メニューは?レベルで的中させられるような法則性を避けろって程度の話。

なので前回の失敗例でとりあげたSystem.IO.Path::GetRandomFileNameだけども、この条件であれば満たしてはいるのだ。

$workdir = [System.IO.Path]::GetRandomFileName()
Write-Host $workdir

これを実行すると

23zgemoa.mqf

と乱数から生成した8.3形式のファイル名(英数小文字)を返すので、可能性は36^11通りとなる。

あるいはInstallShieldなんかのインストーラーのようにGUIDを使ってもいい、.Net Frameworkの GUID構造体を使えばかんたん。

$workdir =  [Guid]::NewGuid().ToString("B")
Write-Host $workdir

これを実行すると

{efd60a90-6ba7-489d-af7b-39c755cb7f87}

と128ビットのうちバリアントとバージョンのためビットを除いた122ビットを乱数で埋め、さらに16進を[0-9a-f]の文字に置換えた文字列を出力するので、可能性は2^122通りになる。

あとは重篤なパラノイアを患っていると乱数が本当に乱数かどうか心配になって夜も眠れなくなるけどさすがにそこまでは面倒みきれん、ハードウェア乱数生成器でも買ってください。

なお前回ちらっと触れたmktemp(3)というタッパーウェアにあった古い関数は「XXXXX」で表される6桁のテンプレート部分を

  • プロセスID
  • アルファベット小文字から1文字

で埋めるだけなんですな。

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

int
main(void)
{
	char buf[BUFSIZ];
	snprintf(buf, sizeof(buf), "/tmp/%s.XXXXXX", getprogname());
	printf("current pid: %d\n", getpid());
	printf("mktemp(3) generated: %s\n", mktemp(buf));
}

これを実行すると

$ ./unko
current pid: 11451
mktemp(3) generated: /tmp/unko.11451a

という結果になる、プロセスIDなんぞ余裕でバレるので実質26通りの組合わせしか無いわけで総当り攻撃も余裕。

ちなみに改良されたmkstemp(3)とmkdtemp(3)は乱数を元に62^6通り、さらにテンプレートのXXXXXXを6桁よりも増やして更に可能性を増やすこともできる。

ただ注意が必要なのは、一部のlibcにおけるmkstemp(3)とmkdtemp(3)の実装は、mktemp(3)とまったく同じ生成規則な実質26通りのままで、総当り攻撃が有効なものもある(N6以前とかね)。 とはいえファイル名が予測可能だったとしてもその他の条件で攻撃は防げてるはずなので、ただちに危険というわけではないから無視してもいい。

@適切なアクセス権限でディレクトリを作成する

これPOSIX:2008でmktemp(3)が仕様から削除に至った脆弱性としては予測可能性よりこっちの方が重篤なのだ、こいつは名前を生成するだけの関数なので

char path[PATH_MAX];
snprintf(path, sizeof(buf), "/tmp/%s.XXXXXX", getprogname());
mktemp(path)
mkdir(path, 0777)

のようにプログラマの無知によって不適切なアクセス権限でファイルやディレクトリを作成することをどうやっても防げないのも理由の一つ。 mkstemp(3)やmkdtemp(3)は内部で

  • ファイルなら0600
  • ディレクトリなら0700

で作成したものを返すので、たとえ予測可能な名前であっても権限無ければ攻撃できないのだ。

140                 if (doopen) {
141                         if ((*doopen =
142                             open(path, O_CREAT | O_EXCL | O_RDWR, 0600)) >= 0)
...
146                 } else if (domkdir) {
147                         if (mkdir(path, 0700) >= 0)
...

ではタッパーウェアではなくWindows、それもPowerShellの場合はどうやって適切なアクセス権限でディレクトリを作成すればいいのか。

ディレクトリを作成するコマンドレットは

New-Item -Path 親ディレクトリ -Name ディレクトリ名 -ItemType Directory

とNew-Itemコマンドレットを使用する(mkdirもコマンドでなくこいつのaliasになる)のだけど、こいつのソース Microsoft.PowerShell.Commands.FileSystemProviderを確認すると、実際に実行されるのは.Net FrameworkのSystem.IO.Directory::CreateDirectoryとなっている。

  11 using System.IO;
...
2196         protected override void NewItem(
2197             string path,
2198             string type,
2199             object value)
2200         {
...
2224             itemType = GetItemType(type);
2225
2226             if (itemType == ItemType.Directory)
2227             {
2228                 CreateDirectory(path, true);
2229             }
...
2697         private void CreateDirectory(string path, bool streamOutput)
2698         {
...
2738                     var result = Directory.CreateDirectory(Path.Combine(parentPath, childName));

そんで System.IO.Directoryクラスのドキュメントを確認するとCreateDirectoryには

  • CreateDirectory(String) … Creates all directories and subdirectories in the specified path unless they already exist.
  • CreateDirectory(String, DirectorySecurity) … Creates all the directories in the specified path, unless the already exist, applying the specified Windows security.

という2種類の狂い咲きオーバーロードがある、それぞれの違いは

  • 前者は作成するディレクトリに対してデフォルトのアクセス権を設定する
  • 後者は明示的にアクセス権を設定する

という違いがあるのだけど、New-Item -ItemType Directoryは前者しか呼んでいないので明示的にアクセス権を設定することができないのだ、なんてこったい。

ただこれもまた直ちに影響というわけではない、Windows NT系の場合一時ディレクトリは各ユーザー毎にご用意されるいわゆる「per user tmp」というやつで、環境変数TMP(あるいはTEMP)には

  • %USERPROFILE%\Local Settings\Temp … XP以前
  • %USERPROFILE%\App\Local\Temp … Vista以降

以下が指定されている、そしてこれとは別にSYSTEMユーザーなどが使う環境変数TMP(あるいはTEMP)には

  • %SYSTEMROOT%\Temp

以下が指定されている。

前者の一時ディレクトリを含む%USERPROFILE%(例えばC:\Users\ユーザー名)以下はデフォルトでは

  • SYSTEMユーザ
  • Administratorsグループ
  • 当該ユーザ

以外にはアクセス権限が無いので、CreateDirectory(String)を使って親ディレクトリのアクセス権限を引継いだまま作成してれば、いちいちSet-Acl呼ばなくても攻撃者からは参照できないので安全だと主張もできなくはない。

ただしSYSTEMユーザなどが使う%SYSTEMROOT%\Temp以下は上記に加えて

  • Creator Ownerユーザ(ファイル作成者)
  • Usersグループ

にもファイルやディレクトリ作成と読み書きが許可されていて(若干の制限はある)、セキュリティは緩めなのよね。

なので%SYSTEMROOT%\Tempを使うユーザー権限で動作するスクリプトを書く場合、ファイル名の予測不可能性が破られた時の事を考えてきっちりアクセス権を設定しておかないとアウトなのだ。

それに環境変数なんていくらでも汚染できるので、TMPあるいはTEMPがWindows 9x系までの頃の流儀であるC:\Tempに書き換えられてたり、SSDの寿命を延ばすためD:\Tempとか自分で掘ってそっち使うようにレジストリ含めて変更してる人もいるしな…そういう人アクセス権限とその継承を正しく設定してるとは思えん。

世の中のPowerShell使いたちはどうしてるのか、我々はその謎を解き明かすべくStackOverflowやQiitaといった未開人の住まうジャングルの奥地へと向かった、そこで目にした光景は

$workdir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName()
New-Item -Path $workdir -ItemType Directory
$alc = Get-Acl -Path $workdir
$alc.SetAccessRuleProtection($true, $false)
$alc.Access | ForEach-Object {
	$alc.RemoveAccessRule($_)
}
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule (
	[System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
	[System.Security.AccessControl.FileSystemRights]::FullControl,
	([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),
	[System.Security.AccessControl.PropagationFlags]::NoPropagateInherit,
	[System.Security.AccessControl.AccessControlType]::Allow
)
$alc.AddAccessRule($rule)
# New-Item -> Set-Acl is not Atomic ops, there's TOCTTOU race condition security problem.
Set-Acl -Path $workdir -AclObject $acl
(Get-Acl -Path $workdir).Access
...
Remove-Item -Force -Path $workdir

とディレクトリを作成した後に、Set-Aclコマンドレット(あるいはicalcsコマンド)でアクセス権限を設定し直すというコード例であった。

賢明なプログラマならお気づきだろうけど、New-ItemとSet-Aclに操作が分割されているのでアトミックではない、よってこのわずかな時間を利用してシンボリックリンク攻撃などを成功させてしまう可能性があるのだよね。 まぁアクセス制限を緩める方向性(例えばファイル共有用に誰でも読み書きできるようにするとか)ならええけどさあ…

なのでアトミックに作業用ディレクトリを掘るのであれば内部的にCreateFile(String)を使ってるNew-Itemは禁止、オーバーロードのCreateFile(String, DirectorySecurity)の方を呼ぶことでディレクトリ作成とアクセス権設定を同時にやらんとアカン。

$workdir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName()
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule (
	[System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
	[System.Security.AccessControl.FileSystemRights]::FullControl,
	([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),
	[System.Security.AccessControl.PropagationFlags]::NoPropagateInherit,
	[System.Security.AccessControl.AccessControlType]::Allow
)
$acl.AddAccessRule($rule)
# Atomic ops, there's no TOCTTOU race condition.
[System.IO.Directory]::CreateDirectory($workdir, $acl)
(Get-Acl -Path $workdir).Access
...
Remove-Item -Force -Path $workdir

うーんこの、WindowsのACL複雑すぎんよー…というかCreateDirectory(String, DirectorySecurity)がアトミック操作なのか心配になってきたゾ。

@次回

残りの「競合状態の回避」をどうやって実装するか、そしてNew-TemporaryFileに対してNew-TemporaryDirectoryとでも名付ければいいのか、コマンドレットっぽく使える関数を作るところまで書けたらいいですね…(かなり飽きた)。

2019/05/28(Tue)

[Windows] PowerShellで生活するために - New-TemporaryFileコマンドレット編(その3)

前回はNew-TemporaryFileでは作成できない作業用ディレクトリの作成のため、New-TemporaryDirectory(仮)をどうやって実装したらいいのかというお話の途中まで。

今回は残作業を片づける

なお

@競合状態を回避する(既に存在するディレクトリとは絶対に被らない)

ファイル名の予測不可能性にも限界はあるので、ファイル名の可能性が少ない場合総当り攻撃が有効となる。 例えばN6以前のmkstemp(3)やmkdtemp(3)は実質26通りの組合わせしかないので、総当り攻撃はピースオブケーキだ。

いやケーキ一切れって言うほど簡単か?(甘いもの苦手マン)

これを防ぐには、同名のファイルかディレクトリが存在していたら、改めて別の名前で再試行するかエラーを返さないとまずい。

現在までのコード

function New-TemporaryDirectory() {
	$workdir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName()
	$acl = New-Object System.Security.AccessControl.DirectorySecurity
	$rule = New-Object System.Security.AccessControl.FileSystemAccessRule (
		[System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
		[System.Security.AccessControl.FileSystemRights]::FullControl,
		([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),
		[System.Security.AccessControl.PropagationFlags]::NoPropagateInherit,
		[System.Security.AccessControl.AccessControlType]::Allow
	)
	$acl.AddAccessRule($rule)
	[System.IO.Directory]::CreateDirectory($workdir, $acl)
}

このままだと既に同名のディレクトリが存在するケースは一切想定していないので、競合状態が発生しでセキュリティ的によろしくないのだ。

じゃあどうすりゃいいかというと、同名のディレクトリが存在したらCreateDirectoryは失敗するのでそれをハンドリングし、成功するまで繰り返せばよいだけ。

function New-TemporaryDirectory() {
	$acl = New-Object System.Security.AccessControl.DirectorySecurity
	$rule = New-Object System.Security.AccessControl.FileSystemAccessRule (
		[System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
		[System.Security.AccessControl.FileSystemRights]::FullControl,
		([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),
		[System.Security.AccessControl.PropagationFlags]::NoPropagateInherit,
		[System.Security.AccessControl.AccessControlType]::Allow
	)
	$acl.AddAccessRule($rule)
	$tmpdir = [System.IO.Path]::GetTempPath()
	for (;;) {
		$path = $tmpdir + [System.IO.Path]::GetRandomFileName()
		try {
			[System.IO.Directory]::CreateDirectory($path, $acl)
		} catch {
			continue;
		}
		return $path
	}
}

あとはそもそも書込み権限が無いなどのケースで無限ループに入らないように、catchステートメントで補足する例外を絞ればいいはず。

ガハハ、勝ったな風呂入ってくる。

@ち~ん(笑)

残念でした(試合結果33-4)、System.IO.Directory::CreateDirectoryはすでにディレクトリが存在する場合であっても例外は飛ばないのだ。なんだこのクソ言語。

ちなみに他の言語のバヤイ

  • C … mkdir(2)はEEXISTを返す
    $ cat >unko.c
    #include <sys/stat.h>
    #include <errno.h>
    #include <limits.h>
    #include <paths.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    
    int
    main(void)
    {
            char path[PATH_MAX];
    
            strlcpy(path, _PATH_TMP, sizeof(path));
            strlcat(path, "{97D12011-4903-44E0-8D3D-FE299CBFDE5F}", sizeof(path));
            if (mkdir(path, 0700))
                    puts(strerror(errno));
    }
    ^D
    $ make unko
    cc     unko.c   -o unko
    $ ./unko
    File exists
    
  • Java … java.nio.file.Files::createDirectory … java.nio.file.FileAlreadyExistsExceptionを投げる
    $ cat >Unko.java
    import java.io.IOException;
    import java.nio.file.FileAlreadyExistsException;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.nio.file.Paths;
    
    public class Unko {
    	public static void main(String[] argv) throws IOException {
    		try {
    			Path path = Paths.get(System.getProperty("java.io.tmpdir"), "{97D12011-4903-44E0-8D3D-FE299CBFDE5F}");
    			Files.createDirectory(path);
    		} catch (FileAlreadyExistsException e) {
    			e.printStackTrace();
    		}
    	}
    }
    ^D
    $ javac Unko.java
    $ java Unko
    java.nio.file.FileAlreadyExistsException: C:\Users\tnozaki\AppData\Local\Temp\{97D12011-4903-44E0-8D3D-FE299CBFDE5F}
    	at java.base/sun.nio.fs.WindowsException.translateToIOException(WindowsException.java:87)
    	at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(WindowsException.java:103)
    	at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(WindowsException.java:108)
    	at java.base/sun.nio.fs.WindowsFileSystemProvider.createDirectory(WindowsFileSystemProvider.java:505)
    	at java.base/java.nio.file.Files.createDirectory(Files.java:689)
    	at unko.Unko.main(Unko.java:13)
    

なんだけどね、Java6以前…?もう時代はJava12ですよ…?

なので他の人はどうしてるかの調査に、再びStackOverflowやQiitaといったジャングルの奥地へ向かうと

	if ([System.IO.Directory]::Exists($path)) {
		[System.IO.Directory]::CreateDirectory($path, $acl)
	}

とPowerShellもC#も事前にディレクトリの存在チェックをすればいいなどというTOCTTOUな競合状態なにそれのクソコードしかありゃしねえ、こいつら消滅しねえかなぁ…

さすがにうっそだろお前とWindows APIのFileAPI.hにあるCreateDirectoryを確認したんだけど、こっちは既にディレクトリが存在する場合にはERROR_ALREADY_EXISTSを返すとなってる、ということで.Net Frameworkの実装がクソなんやなこれ…

不思議なことに、System.IO.Directory::CreateDirectoryでなくNew-Item -ItemType Directoryの場合、-ErrorAction Stopを指定していれば

$path = [System.IO.Path]::GetTempPath() + '{97D12011-4903-44E0-8D3D-FE299CBFDE5F}'
New-Item -ItemType Directory $path
try {
	New-Item -ItemType Directory $path -ErrorAction Stop
} catch [System.IO.IOException] {
	if ($_.Exception.Message -match 'already exists.$') {
		Write-Host $_.Exception.Message
	}
}

これを実行すると

New-Item : An item with the specified name C:\Users\tnozaki\AppData\Local\Temp\{97D12011-4903-44E0-8D3D-FE299CBFDE5F} already exists.
At line:4 char:5
+     New-Item -ItemType Directory $worldir -ErrorAction Stop
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ResourceExists: (C:\Users\tnozak...D-FE299CBFDE5F}:String) [New-Item], IOException
    + FullyQualifiedErrorId : DirectoryExist,Microsoft.PowerShell.Commands.NewItemCommand

ちゃんと例外飛ぶんよね、他の原因のエラーと判別できねえSystem.IO.IOExceptionってのがかなり気に食わないけど、移植性や国際化を一切考慮しないクソコード上等で、例外メッセージ中の「already exists」でもひっかけりゃいい。

ということで一縷の望みを持って該当のエラーメッセージを元にPowerShellのコードを検索してみるとだな…

...
2697         private void CreateDirectory(string path, bool streamOutput)
2698         {
...
2710             if (!Force && ItemExists(path, out error))
2711             {
2712                 string errorMessage = StringUtil.Format(FileSystemProviderStrings.DirectoryExist, path);
2713                 Exception e = new IOException(errorMessage);
2714
2715                 WriteError(new ErrorRecord(
2716                     e,
2717                     "DirectoryExist",
2718                     ErrorCategory.ResourceExists,
2719                     path));
2720
2721                 return;
2722             }
...
2736                 if (ShouldProcess(resource, action))
2737                 {
2738                     var result = Directory.CreateDirectory(Path.Combine(parentPath, childName));

あっ…(察し)、やってることはさっきのStackOverflowなんかに転がってる違反コードと一緒なんやな…だから毎回言ってるだろPowerShellだけはやめとけよと!

んああああああ、Life with PowerShell: A Guide for EveryoneどころかNo Life with PowerShell: Destroy, Kill All Hippiesなんやな。

ということで競合状態を回避してディレクトリを掘るには、Windows APIのFileAPI.hにあるCreateDirectoryを直接呼出す以外に無いわけ。もう PowerShell Coreとか CoreFX(.Net Core)のLinux移植の事まで考える気力は残ってねぇぞ…

@次回

もうめちゃくちゃだよ(怒)、もうC++かC#でモジュール書いたろかって気分だけど、それだと面白くないのでただ今よりPowerShellでWindows APIを使う訓練を開始する、もっと面白くねえわ○すぞ(ガチギレ)。

2019/05/30(Thu)

PowerShellで生活するために - Add-Typeコマンドレット編(その1)

前回の続き、どんどん話が脱線していくけどしょせんは自分用のチラシの裏なのでどうでもいい、なんせPowerShellからWindows APIを呼ばないとセキュリティの基本も満たせないようなクソザコナメクジ言語が悪いよー。

@ネイティブコードを呼びだす(Java JNI編)

なんでPowerShellとは何の関係も無いJavaの話なんですか!まぁシェフの気まぐれサラダ並みに何も考えてない備忘録がてら。なんせチラシの裏だからな。

ネイティブコード呼ぶにはJNI(Java Native Interface)という規則に従って醜悪なC/C++によるブリッジコードを書く羽目になるのだけど、Java屋にC/C++書かせたらそりゃそのラッパー部分ですらメモリリーク起こすしスレッドアンセーフなコード書いてJVMも毎日クラッシュしますがな *1

まぁJavaは前述の通りJava6以降ならjava.nio.file.Files::createDirectoryがjava.nio.file.FileAlreadyExistsException投げるので、今回やりたいこと的にはネイティブコード書く必要も無いんだけど、いまだにJava1.4以降の機能はすべて禁止の縛りプレイやっとる地獄ありそうだしな(しろめ)。

まずJava側ではnative修飾子を使ってメソッド定義だけ書く、NewTemporaryDirectoryとでもしておこう。

package unko;

import java.io.IOException;

public class Unko {
	static {
		System.loadLibrary("unko");
	}
	public native static String NewTemporaryDirectory() throws IOException;
	public static void main(String[] argv) throws IOException {
		System.out.println(NewTemporaryDirectory());
	}
}

System.loadLibraryの引数がDLLなんかの共有ライブラリ名、Windowsならunko.dllが検索されてロードされる。

そんでお次はJava9でjavahコマンドはdeprecatedとなり削除されとったので、javacコマンドで

$ javac -h ヘッダ出力先 Unko.java

としてヘッダファイルを出力する、するとこんなのが出力される。

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class unko_Unko */

#ifndef _Included_unko_Unko
#define _Included_unko_Unko
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     unko_Unko
 * Method:    NewTemporaryDirectory
 * Signature: ()Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_unko_Unko_NewTemporaryDirectory
  (JNIEnv *, jclass);

#ifdef __cplusplus
}
#endif
#endif

あとはこいつの実体を実装し、unko.dllをビルドしてやればおk。

#include <Windows.h>
#include <combaseapi.h>
#pragma comment(lib, "ole32.lib")
#include <strsafe.h>

#include "unko_Unko.h"

#define arraycount(array)       (sizeof((array))/sizeof((array)[0]))

JNIEXPORT jstring JNICALL
Java_unko_Unko_NewTemporaryDirectory(JNIEnv* env, jclass class)
{
	SECURITY_ATTRIBUTES sa;
	DWORD tmplen;
	WCHAR tmp[MAX_PATH], path[MAX_PATH];
	GUID guid;
	LPOLESTR guidstr;

	/* XXX: null security descriptor means default security, so this is NOT SAFE. */
	sa.nLength = sizeof(sa);
	sa.lpSecurityDescriptor = NULL;
	sa.bInheritHandle = TRUE;
	tmplen = GetTempPathW(arraycount(tmp), tmp);
	if (StringCchCopyW(path, arraycount(path), tmp) == S_OK) {
		for (;;) {
			if (CoCreateGuid(&guid) != S_OK)
				break;
			if (StringFromCLSID(&guid, &guidstr) != S_OK)
				break;
			if (StringCchCopyW(path + tmplen, arraycount(path) - tmplen, (LPCWSTR)guidstr) != S_OK) {
				CoTaskMemFree(guidstr);
				break;
			}
			CoTaskMemFree(guidstr);
			if (CreateDirectoryW(path, &sa))
				return (*env)->NewString(env, (const jchar*)path, (jsize)lstrlenW(path));
			if (GetLastError() != ERROR_ALREADY_EXISTS)
				break;
		}
	}
	(*env)->ThrowNew(env, (*env)->FindClass(env, "java/io/IOException"), "something wrong.");
	return NULL;
}

アクセス権周りのコードまで例に入れると長いので省略よってデフォルトのアクセス権がつくことに注意、なので作業用ディレクトリとして使うには安全でない。 PowerShellで書いてすらあの長さなのでWindows API使ってC/C++で書いたら地獄となる典型的なコードだからな…

@ネイティブコードを呼びだす(Java JNA編)

さすがにJNIによる被害多数により、ブリッジコード不要なJNA(Java Native Access)という規格が作られ、現在では以下のコードのようにすべてJavaで書くことも可能となっている *2

package unko;

import java.io.IOException;
import java.nio.file.Paths;
import java.util.UUID;

import com.sun.jna.IntegerType;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.Structure;
import com.sun.jna.Structure.FieldOrder;
import com.sun.jna.win32.W32APIOptions;

public class Unko {
	interface WinError {
		static final int ERROR_ALREADY_EXISTS = 183;
	}
	public static class DWORD extends IntegerType {
		public DWORD() {
			this(0);
		}
		public DWORD(long value) {
			super(4, value, true);
		}
	}
	public static class BOOL extends IntegerType {
		public BOOL() {
			this(false);
		}
		public BOOL(boolean value) {
			super(4, value ? 1 : 0, true);
		}
	}
	@FieldOrder({"nLength", "lpSecurityDescriptor", "bInheritHandle"})
	public static class SECURITY_ATTRIBUTES extends Structure {
		public DWORD nLength;
		public Pointer lpSecurityDescriptor;
		public BOOL bInheritHandle;
	}
	public interface Kernel32 extends Library {
		Kernel32 INSTANCE = (Kernel32)Native.load("kernel32", Kernel32.class, W32APIOptions.DEFAULT_OPTIONS);
		boolean CreateDirectory(String lpPathName, SECURITY_ATTRIBUTES lpSecurityAttributes);
		int GetLastError();
	}
	public static String NewTemporaryDirectory() throws IOException {
		/* XXX: null security descriptor means default security, so this is NOT SAFE. */
		SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
		sa.nLength = new DWORD((long)sa.size());
		sa.lpSecurityDescriptor = null;
		sa.bInheritHandle = new BOOL(true);
		String tmp = System.getProperty("java.io.tmpdir");
		for (;;) {
			String path = Paths.get(tmp, UUID.randomUUID().toString()).toString();
			if (Kernel32.INSTANCE.CreateDirectory(path, sa))
				return path;
			if (Kernel32.INSTANCE.GetLastError() != WinError.ERROR_ALREADY_EXISTS)
				break;
		}
		throw new IOException("something wrong.");
	}
	public static void main(String[] argv) throws IOException {
		System.out.println(NewTemporaryDirectory());
	}
}

長げえよクソが、ただでさえドイヒーな設計なWindows APIに大量に存在する独自な型定義をいちいちJavaでの定義に再翻訳する必要あって二重苦もいいとこですわやっぱりちゃんとC/C++書けるプログラマ連れてきた方が楽なんじゃねえの(テノヒラクルー)。

それにNobody RunsならぬRun Anywareが信条のJavaだし、直接システムのネイティブコードを呼ぶのではなくブリッジコードでOSの違いを吸収しとけってのも正しいんだけどね…

話を戻して、Run Anywhereを捨てて直接システムのネイティブコード叩くなら、Windows APIのようなWell Knownなものは contribにぜんぶ定義済のコードがあるので、わざわざ自分で書かずにjarつっこめば再翻訳の手間だけは省ける(楽になるとはいっていない)。

package unko;

import java.io.IOException;
import java.nio.file.Paths;
import java.util.UUID;

import com.sun.jna.platform.win32.Kernel32;
import com.sun.jna.platform.win32.WinBase;
import com.sun.jna.platform.win32.WinError;

class Unko {
	public static String NewTemporaryDirectory() throws IOException {
		/* XXX: null security descriptor means default security, so this is NOT SAFE. */
		WinBase.SECURITY_ATTRIBUTES sa = new WinBase.SECURITY_ATTRIBUTES();
		sa.lpSecurityDescriptor = null;
		sa.bInheritHandle = true;
		String tmp = System.getProperty("java.io.tmpdir");
		for (;;) {
			String path = Paths.get(tmp, UUID.randomUUID().toString()).toString();
			if (Kernel32.INSTANCE.CreateDirectory(path, sa))
				return path;
			if (Kernel32.INSTANCE.GetLastError() != WinError.ERROR_ALREADY_EXISTS)
				break;
		}
		throw new IOException("something wrong.");
	}
	public static void main(String[] argv) throws IOException {
		System.out.println(NewTemporaryDirectory());
	}
}

@ネイティブコードを呼びだす(C# P/Invoke編)

一方C#などの.Net Frameworkの場合、最初からこのJNAと似た「P/Invoke(Platform Invoke)」という仕組が用意されている。 なお「ピ○ボケ」と読んでしまうと放送禁止用語に抵触するので以下略、電気羊のイジドアかな?

そういえばC#っていちども仕事で使ったこと無いんだよね、なのでどういう言語かは

Borlandのリストラを逃れたHejlsbergとJ++を待っていたのはまた地獄だった
Microsoftを訴えたのはInpriseと名を変えた古巣とJavaのSun Microsystem、OSとRADの百年戦争が生み出した法廷闘争
J++とDelphi、Windows APIとOLE/COM/ActiveXをコンクリートミキサーにかけてぶちまけた
それがVisual Studioの最新リリース

次回「.NET」

来週もHejlsbergと地獄に付き合ってもらう

という認識でよかったんだっけ…まってC#作ったのになんでJ#作ったんだ…?

そいやかつて彷徨ってた見知らぬ街の住人たち、いまだに後生大事にVisual Basic 6.0を使い続けているのだろうか、まぁWindows 10 32bitでRuntimeサポートされ続けるそうだし使い続けるんだろうな…むせる。

んでC#でのコード例はとりあえずこんな感じ、アクセス権周りについてはやっぱり省略。

using System;
using System.IO;
using System.Runtime.InteropServices;

namespace unko {
	class Unko {
		internal const int ERROR_ALREADY_EXISTS = 183;
		[StructLayout(LayoutKind.Sequential)]
		internal struct SECURITY_ATTRIBUTES {
			internal uint nLength;
			internal IntPtr lpSecurityDescriptor;
			internal bool bInheritHandle;
		}
		[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto, BestFitMapping = false)]
		internal static extern bool CreateDirectory(string path, ref SECURITY_ATTRIBUTES lpSecurityAttributes);
		static String NewTemporaryDirectory() {
			/* XXX: null security descriptor means default security, so this is NOT SAFE. */
			SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
			sa.nLength = (uint)Marshal.SizeOf(sa);
			sa.lpSecurityDescriptor = IntPtr.Zero;
			sa.bInheritHandle = true;
			String tmpdir = Path.GetTempPath();
			for (;;) {
				String path = tmpdir + Path.GetRandomFileName();
				if (CreateDirectory(path, ref sa))
					return path;
				if (Marshal.GetLastWin32Error() != ERROR_ALREADY_EXISTS)
					break;
			}
			throw new IOException();
		}
		static void Main(string[] args) {
			Console.WriteLine(NewTemporaryDirectory());
		}
	}
}

いちいち構造体やらを再定義する手間はJNAに似てるけど *3、アトリビュート書くだけでネイティブコードをクラスメソッドにできるのはJNIのnative修飾子まではいかないけど手軽よね、しかもJNIと違ってブリッジコード不要だしJNAのようにpublicにする必要も無い。

Javaもどうでもいいとこにアノテーション乱用して地獄絵図になっとるのにこういうとこで使わないからクソ言語といわれるのだ。まぁちょっとP/Invokeのこのアトリビュート名はWindowsベタ過ぎるきらいはあるけどな…と思ったらMonoのドキュメント読んだら

[DllImport ("libc.so")]
private static extern int getpid();

うーんこの、LinuxでもDllImportなのか…

ところでマーシャルという用語をみるたびに何段にも積まれたギターアンプを想像するんだけど語源一緒なんだな、そもそも人名だったり軍隊とか警察のお偉いさんの意味なのに、なんでコンピューター用語では尻洗いズみたいな意味になっているのか…

@PowerShellからP/Invokeを使う

ようやく本題のPowerShellのお話、だが残念なことにPowerShellそのものにはP/Invokeの機能は備わっていないのだ。 しかしAdd-Typeコマンドレットというものを使うと、PowerShellスクリプト中にC#のコードが書けてるので、その中でP/Invokeすることで代用することができる。

Add-Typeに渡すC#コードは文字列なので、読みやすいようヒアドキュメント風に書くと読みやすい。

Add-Type -Language 'CSharp' -TypeDefinition @'
namespace unko {
	class Unko {
		...
		[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto, BestFitMapping = false)]
		internal static extern bool CreateDirectory(string path, ref SECURITY_ATTRIBUTES lpSecurityAttributes);
		static String NewTemporaryDirectory() {
			...
		}
	}
}
'@
[unko.Unko]::NewTemporaryDirectory()

そういえば20年経ってもヒアドキュメント実装されてなくて 未だに提案止まりのクソ言語がありましたね…

ちなみに名前空間やクラス名はオプションでも指定可能、その場合は-TypeDefinitionでなく-MemberDefinitionを使う

Add-Type -Language 'CSharp' -Namespace 'unko' -Name 'Unko' -MemberDefinition @'
static String NewTemporaryDirectory() {
	...
}
'@
[unko.Unko]::NewTemporaryDirectory()

たいして省力化できないし判りづらいよなこれ。

まぁともかくバックグラウンドで

  • -TypeDefinition/-MemberDefinitionの引数の文字列が一時ファイルに書きだされ
  • -Languageで指定した言語に対応するコンパイラが一時ファイルをコンパイル
  • ビルドされたライブラリ(アセンブリ)をPowerShellのAppDomainに動的ロードされる

という感じ。

タッパーウェアシェルスクリプトでもCソースをヒアドキュメントで書いて、それを標準入力でコンパイラに食わせて実行ファイル作るみたいな苦し紛れ考えることはあるでしょ。

#!/bin/sh
f=/var/tmp/unko
gcc -xc - -o $f >/dev/null << EOF;
#include <paths.h>
#include <stdio.h>
#include <stdlib.h>
int
main(void)
{
	char buf[PATH_MAX];
	char *path;
	strlcpy(buf, _PATH_TMP, sizeof(buf));
	strlcat(buf, "unko.XXXXXX", sizeof(buf));
	path = mkdtemp(buf);
	if (path == NULL)
		exit(EXIT_FAILURE);
	puts(path);
	exit(EXIT_SUCCESS);
}
EOF
tmpdir=`$f`
rm -f $f
...

ところであなたがお使いの開発マシンだけでなく、実運用環境にもコンパイラがあるってちゃんと確認しましたか(小声)。

PowerShellの場合はとくに.Net Framework SDKやVisual Studioなどがインストールされてる必要は無くランタイムだけでいいようなので、今回みたいに使わざるをえない状況なら使ってけという感じ。

ただし注意点はAdd-TypeはできてもRemove-Typeが存在しないので(AppDomainの仕様らしい)ちょこっと書換えて再実行とかすると

Add-Type : Cannot add type. The type name 'unko.Unko' already exists.
At line:1 char:1
+ Add-Type -Language 'CSharp' -Namespace 'unko' -Name 'Unko' -MemberDef ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (unko.Unko:String) [Add-Type], Exception
    + FullyQualifiedErrorId : TYPE_ALREADY_EXISTS,Microsoft.PowerShell.Commands.AddTypeCommand
 

とエラーが出て、古いコードが実行されて事故起こす可能性があるのがなんともアレ。-ErrorAction Stopとかしても例外は飛ばないようだしどうやって回避したもんですかね…

@次回

完全に飽きてるんだけど、残りのコード書いて完成させるよ…

*1:某所でWindowsから某商用UNIX向けに移植されたライブラリがグローバル変数だらけでJNIでクラッシュ以下略、機密漏洩とか大事故になるくらい動作してたら恐ろしいとこだったって通りすがりのイルカがいってた。
*2:とはいえJNAもJNIベースの技術なので最低限のブリッジコードはあるんだけどね、libffiベースで書かれててFreeBSDやOpenBSD用のバイナリもリリースに含まれている、なおNなんとかBSD(限界集落)
*3:こっちももしかするとJNAのcontribみたいに定義済のものあるのかもしれない、内部的にはCoreLibのInteropを使ってるみたいだけど公開されてるんかなこれ。

2019/05/31(Fri)

[Windows] PowerShellで生活するために - New-TemporaryFileコマンドレット編(その4)

@適切なアクセス権限でディレクトリを作成する

これこれの続き。

残課題として残ってた(馬に乗馬)、アクセス権の設定に関する部分だけど

  • C/C++でのCreateDirectory … アクセス権を表すSECURITY_DESCRIPTOR構造体を作ってSECURITY_ATTRIBUTE構造体に詰めて渡す
  • .Net FrameworkでのCreateDirectory … アクセス権を表すFileSystemAccessRuleクラスを作ってDirectorySecurityクラスに詰めて渡す

という違いがある、この違いをどうやって埋めるか。

ちなみにC/C++でSECURITY_DESCRIPTOR構造を作るには以下のチュートリアルを読んで絶望するといい

なんだこのクソ設計(しろめ)、ちなみにほとんどのケースでエラー処理省いてるのでちゃんと書こうと思うと倍以上の長さのコードになる。

特に個人的に泡吹いて倒れそうになるのがPACL構造体ポインタに割り当てるメモリサイズの計算に

  PACL pDacl = NULL;
  DWORD cbDacl = 0;

  // Calculate the amount of memory that must be allocated for the DACL.  
  cbDacl = sizeof(ACL) + sizeof(ACCESS_ALLOWED_ACE)*3 - sizeof(DWORD)*3;  
  cbDacl += GetLengthSid(pTokenUser->User.Sid);  
  cbDacl += GetLengthSid(pEveryoneSid);  
  cbDacl += GetLengthSid(pTrustedUserSid);  
  
  // Create and initialize an ACL.  
  pDacl = (PACL) new BYTE[cbDacl];  

ってどんなハーブキメて設計するとこんなデザインになるんですかね、いやまあ構造体の後ろに可変長データくらいはCでよく使うけどさぁ。

普段からこういうコードばっかり書いてるとC死ねいいたくなるんだろうとは思う、Always Look On The Bright Side Of Cと歌いながら綺麗なコードだけ読んでる世界の人にはあまりピンと来ないんだけどね…

これをさらにP/Invoke経由でC#で書こうとかショットガンで自分の頭打ち抜くようなもの、幸いなことにそんな自殺行為をせんでもDirectorySecurityからSECURITY_DESCRIPTORに変換するメソッドがご用意されているので、そいつを使わせて貰って全力で回避する。

using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;

namespace unko
{
	class Unko
	{
		internal const int ERROR_ALREADY_EXISTS = 183;
		[StructLayout(LayoutKind.Sequential)]
		internal struct SECURITY_ATTRIBUTES
		{
			internal uint nLength;
			internal IntPtr lpSecurityDescriptor;
			internal bool bInheritHandle;
		}
		[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto, BestFitMapping = false)]
		internal static extern bool CreateDirectory(string path, ref SECURITY_ATTRIBUTES lpSecurityAttributes);
		static String NewTemporaryDirectory() 
		{
			DirectorySecurity ds = new DirectorySecurity();
			ds.AddAccessRule(new FileSystemAccessRule(
				WindowsIdentity.GetCurrent().Name,
				FileSystemRights.FullControl,
				InheritanceFlags.ContainerInherit| InheritanceFlags.ObjectInherit,
				PropagationFlags.NoPropagateInherit,
				AccessControlType.Allow
			));
			SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
			sa.nLength = (uint)Marshal.SizeOf(sa);
			sa.bInheritHandle = false;
			byte[] sdb = ds.GetSecurityDescriptorBinaryForm();
			int sdn = sdb.Length;
			sa.lpSecurityDescriptor = Marshal.AllocHGlobal(sdn);
			try {
				Marshal.Copy(sdb, 0, sa.lpSecurityDescriptor, sdn);
				String tmpdir = Path.GetTempPath();
				for (;;) {
					String path = tmpdir + Path.GetRandomFileName();
					if (CreateDirectory(path, ref sa))
						return path;
					if (Marshal.GetLastWin32Error() != ERROR_ALREADY_EXISTS)
						break;
				}
			} finally {
				Marshal.FreeHGlobal(sa.lpSecurityDescriptor);
			}
			throw new IOException();
		}
		static void Main(string[] args)
		{
			Console.WriteLine(NewTemporaryDirectory());
		}
	}
}

このコード中のDirectorySecurity::GetSecurityDescriptorBinaryFormがそれなんだけど、扱いがちょっとややこしくて

  • DirectorySecurityはマネージドなメモリ(byte配列)で返す
  • SECURITY_ATTRIBUTESへはアンマネージドなメモリ(IntPtr)に変換して渡す

必要があるので変換が必要 *1、アンマネージドなメモリはMarshal::AlocHGlobalで取得できる、もちろんガベコレは面倒みてくれないのでメモリリークの無いよう、Marshal::FreeHGlobalで使い終わったら解放する。なんかフリーエッチグローバルって書くとフリー○ックスっぽくてアレやな。

ちなみにUNIXの場合root権限はどうあがいても無敵なので考慮する必要はないんだが、Windowsの場合だと管理者からも読めないディレクトリとなってしまうので *2、AdministratorsグループとかSYSTEMユーザにも許可を与える必要があるかもしれないので必要な方は適宜修正してどうぞ。

			ds.AddAccessRule(new FileSystemAccessRule(
				new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
				FileSystemRights.FullControl,
				InheritanceFlags.ContainerInherit| InheritanceFlags.ObjectInherit,
				PropagationFlags.NoPropagateInherit,
				AccessControlType.Allow
			));
			ds.AddAccessRule(new FileSystemAccessRule(
				new SecurityIdentifier(WellKnownSidType.BuiltinSystemOperatorsSid, null),
				FileSystemRights.FullControl,
				InheritanceFlags.ContainerInherit| InheritanceFlags.ObjectInherit,
				PropagationFlags.NoPropagateInherit,
				AccessControlType.Allow
			));

既知のユーザー一覧は System.Security.Principal.WellKnownSidTypeあたりを参照のこと、というかWindows ACL複雑すぎてほんと正解が判らん…

とりあえずここまで書いてAdd-TypeにくべてやればようやっとPowerShellでも

  • 他の誰からも読めない安全な作業用ディレクトリを
  • TikTokだかTOCTTTTTTTTTOUだかといった競合状態の心配なくアトミックに

掘ることができたわけだ、いやー大変でしたね…

Add-Type -TypeDefinition @'
...
namespace unko
{
	class Unko
	{
...
'@
[unko.Unko]::NewTemporaryDirectory()

@次回

このままでも十分実用できなくもないんだけど、NewTemporaryDirectoryの実行結果をパイプで他のコマンドレットに渡せないのが微粒子レベルでめんどくさい可能性があるので、unko.Unkoクラスをコマンドレットとして使えるようリファクタリングしてみる。

*1:アンセーフなプロジェクトとしてビルドすればfixedステートメントでもいけるらしいがここはセーフ○ックスで。
*2:まぁどっちみち管理者ならTakeownコマンドなんてあるくらいで権限割当て直せるから無駄に複雑になるだけよね…