バッチや JScript に PowerShell を埋め込んでダブルクリックで起動する Polyglot 色々

Pocket

本記事は、 シェルスクリプト&PowerShell Advent Calendar 2024 2日目の記事だ。
まだまだ始まったばかり。 執筆者求む、執筆者!


さて、 PowerShell スクリプトファイル (.ps1) は、 WSH や Office VBA のウイルス蔓延を許した反省からか、 Windows の標準ではスクリプトファイルをダブルクリックしても実行できない。
ただ、これだと人にファイルを渡して実行してもらうのに手間がかかるので、特別な関連付け設定をせずダブルクリックで実行できる PowerShell スクリプトを作りたい。

ひとつのスクリプトファイルを、異なる複数のプログラミング言語で正しく解釈できるように書く、 Polyglot というテクニックがある。
そういったテクを活用して、「ダブルクリックで起動できるバッチやスクリプトを使って、自分自身を PowerShell スクリプトと解釈させて実行」する単一ファイルを作成する方法をいくつか考えてみよう。

仕組み上、 PowerShell スクリプトファイル (.ps1) を介さないので、以下のような制限がある。

  • 標準入力を受け付けられない (パイプ先になれない)
  • PowerShell 的にはスクリプトファイルを実行しているわけではないので、 $PSScriptRoot, $PSCommandPath, $MyInvocation.MyCommand.Path あたりが空になる

このため以降で紹介するどのパターンでも、自身のファイルのフルパスを環境変数 SCRIPT_PATH に入れてから PowerShell を呼び出している。
PowerShell スクリプト内では $env:SCRIPT_PATH を呼び出すことで、少なくとも後者の問題については回避できる。

コンソールウィンドウありバッチファイル

まず、以下の内容のテキストを、 .bat.cmd の拡張子で BOM無しUTF8 で保存しよう。

<# : &@SETLOCAL&PUSHD "%~dp0"&SET SCRIPT_PATH=%~f0&powershell.exe -NoL -Sta -NoP -c "&{gc -li $env:SCRIPT_PATH -Raw -Enc UTF8|iex}"&POPD&ENDLOCAL&GOTO :EOF
<###############################################################
Polyglot for PowerShell as Batch Script (with Console Window)
###############################################################>
Add-Type -AssemblyName System.Windows.Forms;
[System.Windows.Forms.MessageBox]::Show("Hello Location: $(gl)");

ブロックコメントが終わる5行目以降の内容が、 PowerShell スクリプトとして実行される。

早速実行してみると、 PowerShell スクリプト実行中は、以下のようにコンソールウィンドウが開きっぱなしとなる。

それが許容できるなら、一番シンプルな実装で良いと思う。

バッチファイルと PowerShell の Polyglot の解説

Polyglot の仕組みについても少し解説をば。

<# :

先頭のこの部分は、 PowerShell として解釈された時にブロックコメントとして無視されるようにすると同時に、コマンドプロンプトとしては何もしないコードとなっている。

これ→ "<" はお馴染み標準入力をファイルからリダイレクトする記号だ。
つまり、 "#" というファイルを探してリダイレクトを試行している。
ほとんどの場合カレントディレクトリに "#" というファイルはないので、 "指定されたファイルが見つかりません。" とエラーは表示されるものの、次のコマンドに移る。

そして、これ→ ":" は何もしないコマンドだ。
実はリダイレクトとコマンドを記述する順序はどちらが先でもよく、 "sort < file.txt" も "< file.txt sort" も同じように動作する。
つまり、 "<# :" は ": < #" と同等で、何もしないコマンドに対して適当なファイルをリダイレクトしている訳だ。

以降は、 powershell.exe-WindowStyleHidden オプションを指定して起動し、自身のファイルを Invoke-Expression コマンド (iex) にで実行している。
PowerShell と解釈されたこのファイルは、 "<#" から "#>" までブロックコメントとして扱うので、5行目以降が実行されるのだ。

PowerShell の処理完了後、バッチスクリプト側の "POPD&ENDLOCAL&GOTO :EOF" でファイルの末尾に移動して処理が終了する。

コンソールウィンドウなしバッチファイル(ちらつきあり)

こちらも、以下の内容のテキストを、 .bat.cmd の拡張子で BOM無しUTF8 で保存しよう。

<# : &@SETLOCAL&PUSHD "%~dp0"&SET SCRIPT_PATH=%~f0&START powershell.exe -NoL -Sta -NoP -Win Hidden -c "&{gc -li $env:SCRIPT_PATH -Raw -Enc UTF8|iex}"&POPD&ENDLOCAL&GOTO :EOF
<###############################################################
Polyglot for PowerShell as Batch Script (No Window)
###############################################################>
Add-Type -AssemblyName System.Windows.Forms;
[System.Windows.Forms.MessageBox]::Show("Hello Location: $(gl)");

一見ひとつ前とほぼ同じようなコードだが、 powershell.exe の前に START コマンドがつき、 powershell.exe-WindowStyle Hidden オプションの指定が増えている。

これによりスクリプト実行中常に PowerShell ウィンドウが出たままになるのを防いでいる。

ただ、上記のように起動時に2回 (START を実行するまでのコマンドプロンプトウィンドウと、 powershell.exe 起動時にウィンドウが開いてから -WindowStyle Hidden オプションが効いてウィンドウが消えるまでの間) コンソールウィンドウ画面がちらついて表示されてしまう。

Polyglot の仕組みは、前項と全く同じなので解説は省く。

コンソールウィンドウなし WSH (ちらつきなし)

今度は、以下の内容のテキストを、 .jse の拡張子で BOM付きUTF-16 で保存する。

'\'>$null;'; var WshShell = WScript.CreateObject("WScript.Shell"); var fso = WScript.CreateObject("Scripting.FileSystemObject"); WshShell.CurrentDirectory = fso.GetParentFolderName(WScript.ScriptFullName); WshShell.Environment("Process").item("SCRIPT_PATH") = WScript.ScriptFullName; WshShell.Run("powershell.exe -NoL -Sta -NoP -Win Hidden \"&{gc -li $env:SCRIPT_PATH -Raw -Enc Unicode|iex}\"", 0); /* '>$null;
<###############################################################
Polyglot for PowerShell as WSH JScript (No Window)
###############################################################>
Add-Type -AssemblyName System.Windows.Forms;
[System.Windows.Forms.MessageBox]::Show("Hello Location: $(gl)");

# JScript として解釈した時にエラーとならないように、末尾に以下が必要
# */

ブロックコメントが終わる5行目以降の内容が、 PowerShell スクリプトとして実行される。

また、前ふたつと異なり、余計なコンソールウィンドウが全く表示されずに実行できる。

但し、記述時に以下の2点を気をつける必要がある。

  • 一番最後の行に PowerShell の一行コメントと JScript のブロックコメント終了を示す # */ を記述する
  • PowerShell スクリプトの中で、上記以外に */ の文字の並びが来ないように記述する
    • どうしても文字列の並びで必要な場合は、 '*/' の代わりに '*'+[char]0x2f とするなど工夫する

.jse 拡張子のファイルは、 WSH (Windows Script Host) の JScript (JavaScript のサブセット) のファイルだ。
仕組みとしては、 .jse 拡張子のファイルが Windows 規定で WSH の wscript.exe に関連づけられている事を利用している。

WSH のうち VBScript の方は非推奨化が決まっているが、 JScript のほうはそれが無い為、まだしばらく使えるはずだ。

Windows の規定の設定のままなら、 .jse でも .js でもどちらの拡張子も wscript.exe に関連付けられているので、どっちでも良いはずではある。
しかし、 vscode や IDE などがインストールされていると、 .js のほうの関連付けが書き換えられていがちだ。

一方で、 .jse は WSH の JScript ファイルの一部を簡易的に難読化されたものにつく拡張子だ。 1
但し、ファイル中に //**Start Encode** という文字列が含まれていなければ、通常の JScript と同じように解釈されるので、前述の関連付けの問題の回避のために .jse の拡張子としておくことをオススメする。

コンソールウィンドウがちらつかないのがこの方式の利点だが、 VBScript が終わる以上、将来の Windows で JScript がいつまで標準で使えるのかわからないのが難点だ。
あと、もしかしたら書いたスクリプトがウイルスチェックや EDR に引っかかりやすいかもしれない。

JScript と PowerShell の Polyglot の解説

Polyglot の仕組みについても少し解説をば。

Polyglot としてのポイントは '\'>$null;'; ... の部分だ。

PowerShell では \ がエスケープされない為、文字列が $null にパイプされ、さらに ; より後ろが文字列と解釈される。
行末でその文字列も再び >$null; にパイプさせ、何も副作用無しに次の行に進む。

一方 JScript (JavaScript) では \' がエスケープとして扱われるので、 "'>$null;" という文字列の後ろにスクリプトが続くと解釈される。 JavaScript では、文字リテラル単体がコード中にあっても ("use strict" 以外は) 副作用がないので、そのままこの文字列は無視される。
続く JScript スクリプトでは、カレントディレクトリの変更とプロセス内環境変数の追加を行った後、 powershell をウィンドウ無しで起動している。 やってる内容は前項のバッチスクリプトでの例と同じだ。
PowerShell 用に書かれた行末の '>$null; は、その手前で JScript のブロックコメント /* を開始して、以降の PowerShell コードをすべて無視する。

バッチスクリプトの場合と異なり、ファイルの末尾に # */ と書いて JScript のブロックコメントを閉じなくてはならないのが非常にダサいが、良い解決方法が思いつかなかったのでこのままとした。

ファイルの文字コードを BOM付きUTF-16 としているのは、 JScript では BOM付 UTF-8 は扱えず、 BOM無しだと PCのロケールに対応した MBCS (日本語なら Shift-JIS) 扱いとなってしまい、都合が悪いためだ。

JScript 内で利用している WScript.ShellScripting.FileSystemObject の COM オブジェクト操作についての説明は省くが、カレントディレクトリと移動させたり、自身のスクリプトを SCRIPT_PATH 環境変数に設定したりと、実行している内容は前ふたつと同じだ。

WSH 周りのドキュメントは Microsoft にあまり残っていないが、 Scripting | Microsoft Learn の下にある、 Script Runtime | Microsoft LearnReference (Windows Script Host) | Microsoft Learn オブジェクト一覧あたりに API 情報が記載されているので、興味があれば読み解いてみてはいかがだろうか。
(ちなみに、 WScript.Shell で呼び出したオブジェクトは WshShell Object のページにリファレンスがある)

おわりに

以上、バッチスクリプトや WSH JScript に偽装して PowerShell をダブルクリックで実行する方法だ。

それぞれ一長一短あるので、自分らの用途に応じてベストなものを選んでくれ。

蛇足

実は Windows 上であれば PowerShell (v5.1 / v7 両方) でも $wshShell = New-Object -ComObject WScript.Shell; などと書くことで COM オブジェクトを扱えたりする。
最終的に PowerShell で実装するならコマンドレットや .NET の標準ライブラリ使った方が良いが、 PowerShell なら REPL が簡単に使えるので、 COM の動作確認する用途では役に立つ。

コメントを残す

メールアドレスが公開されることはありません。

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください