Node.js から powershell.exe -Command を呼び、利用者が渡した文字列を単一引用符の中へ埋め込む道具を書いていました。
`Export-ScheduledTask -TaskName '${task}'`
このままだと、task に単一引用符を含む名前を渡すと文字列が閉じ、続きが PowerShell の文として実行されます。直し方としてよく見るのは「' を '' に二重化する」ですが、**PowerShell は ASCII の ' 以外も単一引用符として読みます。**私は一度それで直したつもりになり、翌朝に穴が残っていると分かりました。
この記事は、どの文字が閉じるのかを全部数えた記録です。
先に結論
- このPCの Windows PowerShell 5.1(5.1.26100.9444)で、
'a<c>b'を構文解析器に通して 0x0000〜0xFFFF の65,536符号単位すべてを試しました - 文字列が1つのまま読めなかったのは 5文字だけでした
| 符号 | 文字 | 名前 |
|---|---|---|
| U+0027 | ' |
APOSTROPHE |
| U+2018 | ‘ |
LEFT SINGLE QUOTATION MARK |
| U+2019 | ’ |
RIGHT SINGLE QUOTATION MARK |
| U+201A | ‚ |
SINGLE LOW-9 QUOTATION MARK |
| U+201B | ‛ |
SINGLE HIGH-REVERSED-9 QUOTATION MARK |
- 5文字とも、二重化した形(
'a<c><c>b')が元の1文字に読み戻ることも確かめました - したがって埋め込むときは、この5文字をすべて二重化します。ただし言えるのは、1文字ずつ真ん中に置いて試した範囲で、このPCのこの版の構文解析器ではこの5文字以外に閉じる文字が無かった、までです(違う引用符が並ぶ場合などは下の「測っていないこと」に書きました)
私が最初にやった直し方と、その穴
最初の直し方は ' ‘ ’ の3文字を二重化するものでした。‘ ’ が単一引用符として扱われることは知っていたので、それで足りると考えていました。
翌朝、別の用途で前提を測り直すため、似た形の10文字を実際に実行して確かめました。形は次のとおりです(<Q> に試す文字を入れます)。
Write-Output 'x<Q>; Write-Output INJECTED; <Q>'
| 符号 | 文字 | 続く文が実行されたか |
|---|---|---|
| U+0027 | ' |
実行された |
| U+2018 | ‘ |
実行された |
| U+2019 | ’ |
実行された |
| U+201A | ‚ |
実行された |
| U+201B | ‛ |
実行された |
| U+2032 | ′ |
されなかった |
| U+0022 | " |
されなかった |
| U+201C | “ |
されなかった |
| U+201D | ” |
されなかった |
| U+201E | „ |
されなかった |
**‚(下付きの引用符)と ‛(反転した引用符)でも閉じました。**3文字だけを二重化した版の道具に、‚ で閉じる名前を渡すと、注入した文が実行されました。
私の測り方にも穴があった
3文字版を書いた夜、私は「修正後は注入が実行されない」ことを確かめたつもりでした。ところがその測り方は、注入する文の内側に ASCII の ' を含んでいました。' を二重化する版では、注入文そのものが構文エラーになります。つまり ‚ の穴が残っていても「実行されなかった」と出る形でした。
「実行されない」を確かめるときは、同じ測り方で、直す前の版なら実行されることを先に見る必要がありました。翌朝はそれをやったので穴が出ました。
10文字では足りないので、全部数えた
実行して確かめた10文字は、私が「似ている」と思って選んだ文字です。選ばなかった文字の中に閉じるものが無いとは言えません。そこで、構文解析器に全部を通しました。
コマンドは1つも実行しません。[System.Management.Automation.Language.Parser]::ParseInput で字句に分け、「先頭のトークンが単一引用符の文字列で、その値が a<c>b と等しく、構文エラーが0件」なら「閉じなかった」と判定します。値の比べ方は PowerShell の -eq で、大文字小文字を区別せず、カルチャに依存する比較です。構文解析器が値の中の文字を落としたり変えたりしても「等しい」と出る文字がありうるので、厳密な符号単位の一致は確かめていません。
# 2026-10-03: which UTF-16 code units does Windows PowerShell's parser treat as
# a single-quote terminator inside a single-quoted string?
# An earlier run tried 10 characters by execution. This tries every UTF-16 code unit (0x0000-0xFFFF) via the parser
# (no command is executed). ASCII only on purpose: PowerShell 5.1 reads BOM-less files as ANSI.
# Output: JSON on stdout. Read-only; writes nothing.
$ErrorActionPreference = 'Stop'
$closers = New-Object System.Collections.Generic.List[string]
$checked = 0
for ($cp = 0; $cp -le 0xFFFF; $cp++) {
$c = [string][char]$cp
$src = "'a" + $c + "b'"
$tokens = $null; $errs = $null
[void][System.Management.Automation.Language.Parser]::ParseInput($src, [ref]$tokens, [ref]$errs)
$checked++
$t = $tokens[0]
$intact = ($t.Kind -eq 'StringLiteral') -and ($t.Value -eq ('a' + $c + 'b')) -and ($errs.Count -eq 0)
if (-not $intact) { $closers.Add(('U+{0:X4}' -f $cp)) }
}
# Round trip of the doubling escape used by psSingleQuote: 'a<c><c>b' must read back as a<c>b.
$roundTrip = @()
foreach ($cp in @(0x27, 0x2018, 0x2019, 0x201A, 0x201B)) {
$c = [string][char]$cp
$tokens = $null; $errs = $null
[void][System.Management.Automation.Language.Parser]::ParseInput(("'a" + $c + $c + "b'"), [ref]$tokens, [ref]$errs)
$roundTrip += [pscustomobject]@{ codePoint = ('U+{0:X4}' -f $cp); ok = (($tokens[0].Value -eq ('a' + $c + 'b')) -and ($errs.Count -eq 0)) }
}
[pscustomobject]@{
measuredAt = (Get-Date).ToUniversalTime().ToString('o')
psVersion = $PSVersionTable.PSVersion.ToString()
method = "Parser::ParseInput on 'a<c>b' for every UTF-16 code unit 0x0000-0xFFFF (lone surrogates included, since a JS string can carry them); intact = one StringLiteral whose value is a<c>b and no parse errors"
checked = $checked
notIntact = $closers
roundTrip = $roundTrip
} | ConvertTo-Json -Depth 4
このファイルは ASCII だけで書いています。Windows PowerShell 5.1 は BOM の無いファイルを ANSI のコードページで読むので、日本語のコメントを入れると壊れることがあります(これはこの記事で測ったことではありません)。
出力(抜粋)はこうでした。
{
"psVersion": "5.1.26100.9444",
"checked": 65536,
"notIntact": ["U+0027", "U+2018", "U+2019", "U+201A", "U+201B"]
}
roundTrip は5件とも ok: true でした。
**孤立したサロゲート(U+D800〜U+DFFF)も入れています。**JavaScript の文字列はそれを持てるので、Node.js から渡す文字列としては起こりえます。構文解析器に通した範囲では、それも閉じませんでした。ただし Node.js から powershell.exe の引数として渡したとき、孤立サロゲートがそのまま PowerShell まで届くのかは測っていません。
対照
構文解析器で数えた結果が実行の結果と食い違っていないかを、先の10文字で突き合わせました。
- 実行で「閉じた」5文字は、5文字とも構文解析器でも崩れた
- 実行で「閉じなかった」5文字(
′"“”„)は、構文解析器でも崩れなかった
直した関数
export function psSingleQuote(s) {
// PowerShell 5.1 が単一引用符として扱う文字は ' ‘ ’ ‚ ‛ の5つ(2026-10-03 にこのPCで実測)。
// 最初の修正は ‚ ‛ を落としており、その2つで閉じると注入した文が実行されました。
const quotes = [39, 0x2018, 0x2019, 0x201A, 0x201B].map((c) => String.fromCharCode(c)).join("");
const q = quotes[0];
const doubled = String(s == null ? "" : s).replace(new RegExp("[" + quotes + "]", "g"), (c) => c + c);
return q + doubled + q;
}
文字クラスに入れる5文字を、ソースに直接書かず文字コードから組み立てています。エディタや受け渡しの途中で ‘ が ' に置き換わる事故を避けるためです。
**この5文字の集合を、保存した全数の実測と突き合わせるテストも置きました。**関数の側で二重化する文字を65,536符号単位について列挙し、実測の notIntact と一致することを見ます。テストは保存したファイルだけを読み、生きた PowerShell は呼びません。‛ を外すと、このテストは赤くなります。
測っていないこと
- **測ったのは構文解析器であって、実行の経路そのものではありません。**Node.js → CreateProcess →
powershell.exeの引数、という経路で実行して確かめたのは上の10文字だけです - **PowerShell 7(pwsh)では測っていません。**このPCに入っていません
- 他のPC・他の版の Windows PowerShell では測っていません
- **単一引用符の文字列の外に出る注入だけを見ています。**二重引用符の文字列(
$が展開される)に埋め込む場合は、この記事の対象外です - 5文字という数は、このPCのこの版で数えた数です。一般にいつも5文字だとは言っていません
- **試したのは、1文字を真ん中に置いた形と、同じ文字を2つ重ねた形(5文字ぶん)だけです。**違う引用符が並ぶ場合(
‘’など)、引用符が奇数個続く場合、名前の先頭や末尾に来る場合は、保存した測定に入っていません - 値の比較は
-eq(大文字小文字を区別しない・カルチャ依存)で、符号単位の厳密な一致ではありません
やめたこと
- **「似ている文字」を自分で選んで試すこと。**選ばなかった中に答えがありました。数えられる範囲なら全部数えます
- 「実行されなかった」を、直す前の版で実行されることを見ずに信じること
Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを置いています(この記事の関数はその中のものです)。