0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【見えない敵】英数字なのにコピペが通らない?不可視文字とホモグリフの恐怖

0
Posted at

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>&#8203;<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

コピペ前のセルフチェック

  1. 文字数を数える — 想定と違ったら何か入ってる
  2. メモ帳に貼って再コピー — 一部の不可視文字が消える
  3. プレーンテキストとして貼り付け — 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 を信じるな。コードポイントだけが真実。


参考文献


「コピペしたのに動かない」と嘆いている同僚がいたら、この記事を投げつけてやってください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?