TL;DR
英数字だけのパスワードや文字列でも、コピペすると通らないことがある。犯人は「目に見えない文字」か「見た目が同じ別の文字」。特にリモートデスクトップ、Webページ、PDFからのコピペは要注意。
発端:「英数字だけなのに通らない」
パスワード:xK9#mP2$vL
- 手打ち → 通る
- コピペ → 通らない
日本語も記号も関係ない、純粋な英数字と記号だけ。なのに通らない。
diff取っても差分ゼロ。目視でも完全一致。でも認証失敗。
この現象、Unicode正規化(NFC/NFD)問題かと思いきや、英数字には関係ない。
じゃあ犯人は誰だ?
犯人その1:不可視文字(Invisible Characters)
代表的な不可視文字たち
| 名前 | コードポイント | 混入経路 |
|---|---|---|
| BOM (Byte Order Mark) | U+FEFF |
テキストファイルの先頭、クリップボード転送 |
| ゼロ幅スペース (ZWSP) | U+200B |
Webページ、リッチテキスト |
| ゼロ幅非接合子 (ZWNJ) | U+200C |
多言語テキスト処理 |
| ゼロ幅接合子 (ZWJ) | U+200D |
絵文字の合成(肌の色とか) |
| ノーブレークスペース (NBSP) | U+00A0 |
HTML 、Word文書 |
| ソフトハイフン | U+00AD |
自動改行制御用 |
| 改行 (LF) | U+000A |
行末からコピー |
| 復帰 (CR) | U+000D |
Windows改行コード |
全部、見た目は「無」。しかし確実に存在する。
被害例1:パスワードの先頭にBOM
見た目:password123
実際:\uFEFFpassword123
文字数:11文字のはずが12文字
BOM(Byte Order Mark)は本来ファイルの先頭に置いてエンコーディングを示すもの。しかしクリップボード経由でコピペすると、なぜか文字列の先頭にくっついてくることがある。
特にリモートデスクトップのクリップボード転送で頻発。
被害例2:Webページからコピーしたらゼロ幅スペース入り
<!-- Webサイトのソース -->
<span>user</span>​<span>name</span>
<!-- 見た目 -->
username
<!-- コピペ結果 -->
user\u200Bname ← ZWSPが挟まってる
なぜこんなことをするのか?
- コピペ防止(雑な実装)
- 単語の区切り制御
- SEOスパム対策
- トラッキング用のフィンガープリント
悪意の有無に関わらず、被害者はこっちである。
被害例3:Excel/Wordからコピーしたらノーブレークスペース
# Excelのセルからコピー
ABC 123
# 実際の中身
ABC\u00A0123 ← スペースに見えるがNBSP
通常のスペース(U+0020)とノーブレークスペース(U+00A0)は見た目が完全に同じ。しかしバイト列は違う。
" " == "\u00A0" # False
Excelお前もか。
被害例4:改行が末尾についてる
# ダブルクリックで単語選択してコピー...したつもり
password123
# 実際(行末まで選択されてた)
password123\r\n
パスワード入力欄によってはトリムしてくれる。してくれないものもある。
運ゲーである。
犯人その2:ホモグリフ(Homoglyph)
見た目が同じ、でも別の文字
| 見た目 | 正体 | コードポイント |
|---|---|---|
| A | ラテン大文字A | U+0041 |
| А | キリル大文字A | U+0410 |
| Α | ギリシャ大文字アルファ | U+0391 |
| a | ラテン小文字a | U+0061 |
| а | キリル小文字a | U+0430 |
| ɑ | ラテン小文字アルファ | U+0251 |
| o | ラテン小文字o | U+006F |
| о | キリル小文字o | U+043E |
| ο | ギリシャ小文字オミクロン | U+03BF |
| 0 | 数字ゼロ | U+0030 |
| О | キリル大文字O | `U+041E** |
| 1 | 数字イチ | U+0031 |
| l | 小文字エル | U+006C |
| I | 大文字アイ | U+0049 |
| ǀ | ラテン文字歯クリック | U+01C0 |
肉眼での判別は不可能。
被害例5:フィッシングサイトのURLをコピペ
# 見た目
https://www.apple.com/
# 実際(キリル文字使用)
https://www.аррlе.com/
↑↑↑↑↑ 全部キリル文字
「なんか証明書エラー出るな」と思ったらこれ。
被害例6:PDFからコピーした設定値
# PDFのマニュアルからコピペした設定
api_key: аbc123def456
↑ キリル文字の「а」
# エラー
Invalid API key
PDFは内部でフォントを埋め込むため、見た目が同じでも別のコードポイントが使われていることがある。
技術文書のPDF、お前は敵だ。
被害例7:Slackからコピーしたコマンド
# Slackに書いてあったコマンドをコピペ
$ docker run −it ubuntu # ←この「−」
# エラー
unknown shorthand flag: '−' in −it
ハイフンマイナス(U+002D)とマイナス記号(U+2212)は見た目が同じ。
さらに厄介な仲間たち:
| 見た目 | 名前 | コードポイント |
|---|---|---|
| - | ハイフンマイナス | U+002D |
| − | マイナス記号 | U+2212 |
| ‐ | ハイフン | U+2010 |
| – | エヌダッシュ | U+2013 |
| — | エムダッシュ | U+2014 |
| ー | 長音記号 | U+30FC |
全部「横棒」である。殺意が湧く。
被害例8:スマートクォートの罠
# ブログからコピペしたコード
print("Hello World")
# 実行結果
SyntaxError: invalid character '"' (U+201C)
| 見た目 | 名前 | コードポイント |
|---|---|---|
| " | ストレートクォート | U+0022 |
| " | 左ダブルクォート | U+201C |
| " | 右ダブルクォート | U+201D |
| ' | アポストロフィ | U+0027 |
| ' | 左シングルクォート | U+2018 |
| ' | 右シングルクォート | U+2019 |
WordやmacOSは親切心で自動変換してくれる。
ありがた迷惑の極み。
調査方法:バイト列は嘘をつかない
PowerShell(Windows)
# クリップボードの各文字のコードポイントを表示
(Get-Clipboard).ToCharArray() | ForEach-Object {
$code = [int]$_
$hex = "U+{0:X4}" -f $code
$visible = if ($code -lt 32 -or $code -eq 127) { "(制御文字)" }
elseif ($code -eq 32) { "(スペース)" }
else { $_ }
"Code: $hex Char: $visible"
}
# 文字数を確認(不可視文字があれば増える)
"Length: $((Get-Clipboard).Length)"
期待出力(正常)
Code: U+0070 Char: p
Code: U+0061 Char: a
Code: U+0073 Char: s
Code: U+0073 Char: s
Code: U+0077 Char: w
Code: U+006F Char: o
Code: U+0072 Char: r
Code: U+0064 Char: d
Length: 8
異常検知例
Code: U+FEFF Char: (制御文字) ← BOM!
Code: U+0070 Char: p
Code: U+0430 Char: а ← キリル文字!
Code: U+0073 Char: s
Code: U+0073 Char: s
Code: U+0077 Char: w
Code: U+043E Char: о ← キリル文字!
Code: U+0072 Char: r
Code: U+0064 Char: d
Code: U+000A Char: (制御文字) ← 改行!
Length: 11 ← 8文字のはずが11
Python(どこでも使える)
import sys
def inspect_string(s: str) -> None:
"""文字列の各文字を詳細に表示"""
print(f"Length: {len(s)}")
print("-" * 50)
for i, char in enumerate(s):
code = ord(char)
name = f"U+{code:04X}"
# 危険な文字を検出
warnings = []
if code == 0xFEFF:
warnings.append("BOM")
elif code in (0x200B, 0x200C, 0x200D, 0xFEFF):
warnings.append("ZERO-WIDTH")
elif code == 0x00A0:
warnings.append("NBSP")
elif code < 0x20 or code == 0x7F:
warnings.append("CONTROL")
elif 0x0400 <= code <= 0x04FF:
warnings.append("CYRILLIC")
elif 0x0370 <= code <= 0x03FF:
warnings.append("GREEK")
elif code in (0x2212, 0x2010, 0x2013, 0x2014):
warnings.append("DASH-LIKE")
elif code in (0x201C, 0x201D, 0x2018, 0x2019):
warnings.append("SMART-QUOTE")
warn_str = f" ⚠️ {', '.join(warnings)}" if warnings else ""
printable = repr(char) if code < 32 or code == 127 else char
print(f"[{i:2d}] {name} {printable}{warn_str}")
# 使用例
text = input("検査する文字列: ")
inspect_string(text)
オンラインツール
手軽に確認したいなら:
対処法
入力のサニタイズ(サーバー側)
import re
import unicodedata
def sanitize_input(text: str) -> str:
"""入力文字列から危険な文字を除去"""
# BOMを除去
text = text.lstrip('\ufeff')
# ゼロ幅文字を除去
text = re.sub(r'[\u200b-\u200d\ufeff]', '', text)
# 前後の空白・改行を除去
text = text.strip()
# ノーブレークスペースを通常スペースに
text = text.replace('\u00a0', ' ')
# NFC正規化(ついでに)
text = unicodedata.normalize('NFC', text)
return text
コピペ前のセルフチェック
- 文字数を数える — 想定と違ったら何か入ってる
- メモ帳に貼って再コピー — 一部の不可視文字が消える
- プレーンテキストとして貼り付け — Ctrl+Shift+V
ASCII縛り運用
# パスワード生成時はASCII印字可能文字のみ
import secrets
import string
def generate_safe_password(length: int = 16) -> str:
"""安全なパスワードを生成(ASCII印字可能文字のみ)"""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return ''.join(secrets.choice(alphabet) for _ in range(length))
スマートクォート無効化
macOS:
システム設定 → キーボード → 「スマート引用符とスマートダッシュを使用」をオフ
Word:
ファイル → オプション → 文章校正 → オートコレクトのオプション → 入力オートフォーマット → 「左右の区別がない引用符を、区別がある引用符に変更する」をオフ
Webサービス開発者へ:お願い
パスワード入力欄では以下をやってくれ:
// フロントエンド:貼り付け時に警告
passwordInput.addEventListener('paste', (e) => {
const text = e.clipboardData.getData('text');
const dominated = text.replace(/[\u200b-\u200d\ufeff\u00a0]/g, '');
if (text !== sanitized) {
console.warn('不可視文字が検出されました');
// ユーザーに通知するUIを出す
}
});
# バックエンド:保存前にサニタイズ
password = sanitize_input(request.form['password'])
ユーザーを信じるな。クリップボードを信じるな。
まとめ
| 問題 | 原因 | 対策 |
|---|---|---|
| パスワードが通らない | BOM、ZWSP混入 | 文字数確認、手打ち |
| コマンドが動かない | スマートクォート、ダッシュ類似文字 | プレーンテキスト貼付 |
| API keyが無効 | ホモグリフ(キリル文字等) | バイト列確認 |
| 設定値が認識されない | NBSP、不可視文字 | サニタイズ処理 |
結論
目に見えるものが全てではない。バイト列を見ろ。
英数字だから安全?そんなことはない。Unicodeの世界には「見えない文字」と「見た目が同じ別の文字」が無数に存在する。
困ったらまずこれ:
(Get-Clipboard).ToCharArray() | ForEach-Object { "U+{0:X4} {1}" -f [int]$_, $_ }
目視を信じるな。diff を信じるな。コードポイントだけが真実。
参考文献
- Unicode® Technical Report #36 - Unicode Security Considerations
- Homoglyph Attack - OWASP
- Zero-width characters - Wikipedia
「コピペしたのに動かない」と嘆いている同僚がいたら、この記事を投げつけてやってください。