7
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?

「jcode.pl」まだ息してますか? Perl 4 時代の遺物と令和時代の処方箋

7
Last updated at Posted at 2026-09-01

あなたの現場の jcode.pl は、何ですか?

안녕하신게라!パナソニック コネクト株式会社クラウドソリューション部の加賀です。

「永らく非推奨だから、もう現場には居ないだろう」と思われがちなライブラリが、令和のセキュリティ診断や棚卸しで見つかることが稀によくあります。
今回取り上げるのは、それ自体に脆弱性があるわけではないのに発掘率と知名度の高さに定評のある jcode.pl です。名前は聞いたことがある方も多いのではないでしょうか。日本語Webの黎明期を支えた文字コード変換ライブラリで、Perlの世界ではとうの昔に役目を終えたはずの存在です。

先に断っておくと、槍玉に挙げるのは jcode.pl でもその作者でも、ましてやPerlでもありません(Perl本体は現在なお現役で開発が続いています)。Perl 4の時代に日本語を扱う手段は言語側になく、Perlのコード内で完結する手段としては jcode.pl が事実上唯一の実用解でした。
30年以上経った今も動き続けているほど出来のいいライブラリで、問題は役目を終えたものが誰にも気づかれないまま公開ディレクトリに残り続けているという、利用する側の話です。

先に結論

「動いているから触らない」の積み重ねは、令和になって深刻なセキュリティリスクとして表面化します。
技術に詳しくない方向けにまとめてしまうと、このファイルに限らず、定期的な棚卸しと診断がインシデントの芽を摘む一番の近道、という一行に尽きます。

そのうえで、手を動かす方向けの結論を三つ。

  • 探し方
    パッケージマネージャの管理外なので、依存スキャナやSBOMでは取りこぼしやすいです。最後は grepfind で自分の手で確かめてください。探す対象は、呼び出し側のコード、ライブラリ本体、そして jcode.pl.bak のようなバックアップ残骸の三種類です。
  • 直し方
    標準モジュール Encode へ置き換えます。Shift_JISは shiftjis ではなく cp932 です。変換できない文字は既定では例外にならず黙って化けるので、移行中は Encode::FB_CROAK で意図的に落として洗い出してください。
  • 消し方
    いきなり rm はしません。アクセスログで利用実績を確認 → 遮断して様子見 → 削除、の順です。すぐ消せない事情があるなら、遮断だけでも先に実施してください。

本当に見るべきはその先です。置き換えのため変数の流れを追うと、2引数 open() のコマンドインジェクションやCSRF対策の欠如など、より深刻な脆弱性が芋づる式に出てくることがあります。文字コードの手直しで満足せず、周辺まで見てしまうのが正しい対処の作法です。

  • 今まさに Can't locate jcode.pl in @INC で止まっている方 → 応急処置
  • 「うちの現場はPerlじゃない」という方 → おわりにの最後だけでも読んで頂ければ

jcode.pl とは何者か

jcode.pl は歌代和正氏がPerl 4向けに書いた日本語コード変換ライブラリです。
require 'jcode.pl'; で読み込み、&jcode'convert()&jcode'getcode() のように呼び出します1

その後、小飼弾氏によるオブジェクト指向版 Jcode.pm(CPAN、Comprehensive Perl Archive Network配布)が登場しますが、これも現在はほぼ役目を終えています。Perl 5.8.1以降では変換部分が Encode のラッパーになる、とドキュメント自体に明記されています2

つまり系譜は jcode.pl(Perl 4、1990年代前半)→ Jcode.pm(CPAN、オブジェクト指向対応)→ Encode(Perl 5.8標準コア同梱、2002年〜) で、最終形の Encode がPerl本体に取り込まれてから、すでに20年以上が経過しています。jcode.pl は非推奨どころか、とっくに消え去っていておかしくない存在です。

【老人会コラム】閉じないシングルクォート
&jcode'convert()'(シングルクォート)は、今の ::(パッケージ区切り)の前身にあたる古いPerlの記法です。コード中にこの記法が出てきたら、それだけで「Perl 4時代の遺物」と鑑定できます。エディタの色付けが追従できず酷いことになりがちです。
Perl 5で :: が導入されたあとも、後方互換のために ' は今なお使えます。Perl 5.38で非推奨化され、開発版のPerl 5.41.3では予定どおり一度削除されましたが、議論の末、正式版のPerl 5.42.0でデフォルト有効に戻され、apostrophe_as_package_separator フィーチャーで制御する形に落ち着きました3。このフィーチャーが無効になるのは5.41以降のfeatureバンドルだけなので、use v5.42; のようなバージョン宣言を入れない限り、既存コードでは今まで通りこの ' 記法が使えます。

なぜ今も残っているのか

理屈の上ではとっくに消えているはずの jcode.pl が、令和でもまだ見つかる理由は、技術的な問題というより「さわれない事情」に起因していると推測されます。

  • 老舗レンタルサーバ付属のCGI(Common Gateway Interface)資産
    フォームメール、簡易掲示板、アクセスカウンタ、日記CGIなど、2000年代前半に配布されたテンプレート一式がそのまま今も稼働している4
  • 社内の「さわれない」イントラ資産
    動作確認できる人が退職済みのシステム
  • 何年もリニューアルされていない静的サイトの問い合わせフォーム
    制作を委託した先がすでになく、ソースの出所も修正方法も追えない
  • cgi-bin直下に裸で放置された jcode.pl そのもの
    Webサーバの設定次第では、ソースコードがそのままテキストとして閲覧できてしまう事故付き

レンタルサーバ事業者側も、顧客が設置したCGI資産を勝手に書き換えたり削除したりはできないため、連絡や告知をしていても結果的に塩漬けにされやすいという事情があります。「動いているから触らない」が積み重なった結果、jcode.pl は消えないまま現役で残り続けているわけです。

この記事は運用する側の目線で書いていますが、かつて制作を請け負った側にもできることは残っています。手元に納品案件の記録があるなら、jcode で検索をかけて当時の担当先に一報を入れるだけでも十分に価値があります。また、共用サーバで運用しているなら、PerlやPHPのバージョン変更が予告されていないか、.htaccess で何が許可されているか(後述の AllowOverride)をコントロールパネルや事業者のマニュアルで確かめておくと、いざ手を入れるときに詰まりません。

【豆知識コラム】そもそもなぜ文字コード変換が必要なのか
日本語は1バイトでは表現しきれないため、複数バイトを組み合わせて1文字を表す「文字コード」がいくつも生まれました。代表的なものだけでも Shift_JIS(Windows系でおなじみ。半角カナだけは例外的に1バイト)、EUC-JP(Unix/Linux系でよく使われた)、ISO-2022-JP(メール本文でおなじみ、通称「JISコード」)、そして現在主流の UTF-8(同じUnicodeの符号化方式として UTF-16UTF-32 という兄弟もいます)などがあります。
同じ「あ」というたった1文字でも、内部のバイト列はこれだけ違います。

文字コード 「あ」のバイト列
Shift_JIS 82 A0
EUC-JP A4 A2
ISO-2022-JP 1B 24 42 24 22 1B 28 42(エスケープシーケンス込み)
UTF-8 E3 81 82

1990〜2000年代のWebやメールは、送信元と受信先で採用している文字コードが食い違うことが日常茶飯事でした。jcode.plEncode は、この「バイト列の翻訳」を一手に担うライブラリというわけです。

経験則として、縺薙s縺ォ縺。縺ッ(こんにちは)や 繧キ繝輔ヨ(シフト)のような化け方を見ただけで直し方を判別できる猛者も多いのでは。どちらもUTF-8のバイト列をCP932として読んだ結果で、 が並ぶのがこのパターンの目印です。文字コードそのものの詳しい話は長くなるので、また別の老人会記事でじっくりと。

放置しても勝手には見つからない

jcode.pl は、放っておいて自然に炙り出されるような存在ではありません。Perlのアップグレードをきっかけに表面化する経路が二つありますが、どちらも環境に依存する偶然頼みなので、能動的に探す必要があることに変わりはありません。

一つ目は @INC です。Perl 5.26.0(2017年リリース)から、セキュリティ上の理由でモジュール検索パス @INC の末尾からカレントディレクトリ(.)がデフォルトで除外されました5。ここでいう「セキュリティ上の理由」を象徴する事例が、カレントディレクトリに攻撃者が仕込んだ同名モジュールを意図せず読み込んでしまうCVE-2016-1238です6

jcode.plrequire 'jcode.pl'; という書き方で読み込むのが標準的で、これは文字列を指定した require なので @INC を使ってファイルを探しに行きます。カレントディレクトリが @INC から消えたことで、同じ階層に jcode.pl を置いていても見つからなくなり、次のようなエラーで実行が止まることがあります7

Can't locate jcode.pl in @INC (@INC contains: ...) at script.cgi line 3.

サーバのPerlをアップグレードしたり、新しいベースイメージへ移行したりしたタイミングで、それまで何事もなく動いていたスクリプトが急にこのエラーで落ちる形で jcode.pl の存在が発覚することもあります。

また、do 'jcode.pl'; で読み込んでいる場合はこの例外にならず、警告を出して undef を返すだけでスクリプトは先へ進みます8。止まるのは実際に &jcode'convert() を呼び出した行で、Undefined subroutine &jcode::convert called という、ファイルはそこにあるのに関数だけが無い、原因の見えにくいエラーになります。

二つ目は非推奨警告です。&jcode'convert() のシングルクォート区切りはPerl 5.38.0で非推奨になったため、5.38系・5.40系で動かすと Old package separator "'" deprecated がエラーログに溜まっていきます9。ただしこの手掛かりはもう消えています。前述のとおり5.42.0でデフォルト有効に戻され、警告も出なくなりました。

結局どちらも、その現場がどのバージョンをどう経由してきたか次第です。エラーが出るのを待っていても仕方がないので、自分で探しに行きます。

ただ、今まさにそのエラーで本番が止まっている方もいるでしょう。その場合は探すより止血が先なので、次章だけ先に読んでください。それ以外の方は読み飛ばして構いません。

今すぐ動かしたい場合の応急処置

検索でここに辿り着いたのであれば、まずは止血が先でしょう。
検索で出てくるような PERL_USE_UNSAFE_INC=1push @INC, '.'; はヤメてください。(前述のCVE-2016-1238を自分の手で再現する行為です)

正しい止血は、requiredo も、読み込み側で相対パス ./ を明示することです。

require './jcode.pl';   # @INC を汚さず、同じ階層のファイルを直接指す
do './jcode.pl';        # do で読んでいる場合も、直し方は同じ

これはPerl自身が案内している直し方です。
do の失敗時に出る警告は did you mean do "./%s"? という文面で、答えがそのまま書かれています。perl5260deltaも do "./foo_config.pl" を例に挙げて ./ の前置を勧めています。
ただし ./ が指すのはあくまでカレントディレクトリなので、CGIとして呼ばれる分には問題ありませんが、cronなど別の場所から起動されるとズレます。その場合は相対パスを調整するか絶対パスで書いてください。

止血が済んだら、腰を据えて探すところから始めてください。あくまで応急処置ですので。

grepfind での発見方法

jcode.pl はパッケージマネージャ経由ではなく、ファイルを直に置いて読み込む形式のため、コードの一部として密結合してしまっていますcpanfile のような依存関係の宣言を起点に組み立てられる依存スキャナやSBOM(Software Bill of Materials、ソフトウェア部品表)では、取りこぼしやすい部類です。ファイルハッシュやコードスニペットの照合まで行うツールなら検出できることもありますが、そこまで見ている環境ばかりではないでしょう。本記事で grepfind を持ち出すのも確実に洗い出すためです。

手元のコードに jcode.pl が紛れていないか確認します。ここで大事なのは、冒頭で挙げた三種類をもれなく探すことです。呼び出し側だけを探すと、使われていないのにcgi-bin直下に置き去りにされた jcode.pl ファイル単体を見逃します。

# 1. jcode.pl を読み込んでいる/呼び出しているコードを探す
grep -rIniE "(require|do)[[:space:]]*\(?[[:space:]]*['\"][./]*jcode\.pl['\"]|jcode('|::)[a-zA-Z_]+|use[[:space:]]+Jcode\b|Jcode->" .

# 2. ライブラリ本体とその成れの果て(.bak / .old / ~ / .txt など)をまとめて探す
find . -type f -iname 'jcode*'

-i を付けているのは use Jcode; のように大文字で始まる書き方を、\(? を挟んでいるのは require("./jcode.pl") のようにカッコで囲む書き方を拾うためです。find 側を -name 'jcode*.pl' ではなく -iname 'jcode*' にしているのは、末尾が .pl でないコピーも取りこぼしたくないからです。大文字小文字を区別しないので、これ一本で Jcode.pm やそのバックアップまで一緒に引っかかります。

書き方のバリエーションが多いので、探すべきパターンを整理しておきます。

探すもの 実際のコード例 補足
文字列 require での読み込み require 'jcode.pl'; / require "./jcode.pl"; / require("./jcode.pl"); ./ 付きもカッコ付きも定番。前述の @INC 問題に直結する
do での読み込み do 'jcode.pl'; 古い書き方。require だけ探すと取りこぼす
Perl 4記法の呼び出し &jcode'convert(...) / &jcode'getcode(...) コラムで触れたシングルクォート区切り
Perl 5記法の呼び出し &jcode::convert(...) / jcode::convert(...) & が付かない形もある
ライブラリ本体 jcode.pl というファイルそのもの grepではなく find の担当
オブジェクト指向版 use Jcode; / Jcode->new(...) / Jcode.pm 前述の通りPerl 5.8.1以降は Encode のラッパー。大文字なので grep -i が必要
バックアップ残骸 jcode.pl.bak / jcode.pl.old / jcode.pl~ / jcode.txt -name '*.pl' では絶対に引っかからない

いずれかが出てきたら、そのディレクトリは調査対象確定です。

なお、最後の「バックアップ残骸」は本体より厄介です。拡張子が変わった時点でCGIハンドラを通らなくなるため、jcode.pl 本体はCGIとして実行される設定になっていても、jcode.pl.bak はWebサーバから見れば単なる静的ファイルで、URLを直接叩けばソースがそのままテキストとして返ります。「消し忘れ」なので誰の管理台帳にも載っていません。後述の遮断設定も、本体だけを対象にすると意味がありません。

対処法(置き換えから撤去まで)

見つかったら、次はいよいよ置き換えです。特別なツールは不要で、標準モジュールの Encode だけで対応できます。ただし Encode はPerl 5.8以降の標準添付なので、まず perl -v でインタプリタのバージョンを確認してください。

置き換え作業そのものは、1ファイルなら数時間で終わります。時間を食うのはその前後、どこから呼ばれているかの影響調査と、変換後のデータ検証のほうです。工数を聞かれたら、この三つを分けて答えると話が通りやすくなります。

Perl 5.8より前だった場合
CPAN版の Encodeuse 5.007003;(Perl 5.7.3以上)を要求するため、Encodeモジュールだけ後から入れるという逃げ道もありません10。文字コードの書き換えはPerl本体のバージョンを上げたあとの話になります。
すぐに上げられない事情があるなら、後述の撤去手順のうちアクセス遮断だけを先に実施してください。

1. Encode モジュールへの書き換え

# Before(jcode.pl)
require 'jcode.pl';
my $code = &jcode'getcode(\$str);
&jcode'convert(\$str, 'euc', $code);

# After(標準モジュール: Encode, Perl 5.8以降)
use Encode qw(from_to);
from_to($str, 'cp932', 'euc-jp');

入出力の文字コードが既知であれば、自動判定に頼らず from_to($str, $from, $to) のように明示するのが安全です。jcode.plgetcode() のような自動判定は、文字列が短い場合やバイト列がたまたま別の文字コードとしても解釈できてしまう場合に誤判定することがあり、変換方向を取り違えると文字化けやデータ破損に直結します。

(※ shiftjis ではなく cp932 を指定したのには理由があります。後述4で触れます。)

とはいえ、問い合わせフォームのように入力側の文字コードが本当に不定なケースもあります。その場合は当てずっぽうの自動判定ではなく、標準同梱の Encode::Guess候補を絞った上で判定させてください。

use Encode::Guess;
my $decoder = guess_encoding($str, qw/cp932 euc-jp 7bit-jis/);
die $decoder unless ref $decoder;   # 判定できなければエラーメッセージが文字列で返る
my $text = $decoder->decode($str);

候補に shiftjiscp932 のような「規格とそのベンダ拡張版」を同時に入れるのはNGです。包含関係にあるため判定が曖昧になります11

from_to はバイト列同士をその場で変換する関数です。正規表現などで文字単位の操作もあわせて行うなら、my $text = decode('cp932', $str); でPerl内部のUnicode文字列にいったんデコードしてから処理し、出力直前に encode('utf-8', $text) のようにエンコードし直すほうが、マルチバイト文字の境界を誤って分断する心配がなく安全です。

2. Encode では埋まらない穴(半角カナ変換など)

jcode.pl からの移行は「関数を差し替えるだけ」では終わらないことがあります。&jcode'convert() は第4引数に "z" を渡すと、文字コード変換のついでに半角カナを全角カナへ揃えます12Jcode.pmh2z / z2h / jfold も同様で、これらは文字コード変換ではないため Encode の担当範囲外です。

元の機能 移行先
convert() / getcode() Encodefrom_to() / Encode::Guess
convert() 第4引数 "z"h2z(半角カナ→全角カナ) Encode にはなし13。同じくコア同梱の Unicode::Normalize で代替できる(下記参照)
z2h(全角カナ→半角カナ) 標準モジュールに相当機能なし。変換表を自前で持つ
jfold()(全角を2文字分として折り返す) 相当機能なし。文字幅の判定から自前で組む

「自前で組む」が並んでいると身構えますが、実務で引っかかるのはほぼ h2z 側です。フォーム入力の半角カナを全角に寄せる用途が圧倒的に多く、z2hjfold まで使い込んでいるコードは滅多にありません。まずは第4引数に "z" が渡っていないかを探すところからで十分です。

その h2z 相当は、NFKC(互換正規化)を使えば片が付きます。ただし NFKC1 に、A に潰すなど、カナ以外にも容赦なく効きます14半角カナの範囲だけに限定してかけるのが安全です。

use Unicode::Normalize qw(NFKC);

# $text は decode 済みのUnicode文字列。半角カナの範囲(U+FF61〜U+FF9F)だけを正規化する
# 濁点・半濁点は直後に続くので、+ でまとめて拾えば「ガ」→「ガ」まで一息で片付く
$text =~ s/([\x{FF61}-\x{FF9F}]+)/NFKC($1)/ge;

前節で「いったん decode して内部で処理する」と書いたのは、こういう処理を素直に書けるようにするためでもあります。バイト列のままでは、この置換は書けません。

問い合わせフォームから来た半角カナが全角にならなくなった、という形で本番投入後に発覚するやつです。差し替える前に、第4引数が使われていないかを必ず確認してください。

3. I/Oレイヤに変換を任せる

1で触れた「いったん decode して内部で処理する」という発想は、ファイルオープン時にエンコーディングレイヤを指定する形でも実現できます15。読み書きのたびに from_to を呼ぶより、変換漏れが起きません。

open(my $in,  '<:encoding(cp932)', $src) or die $!;   # 読み込み時にデコード
open(my $out, '>:encoding(utf-8)', $dst) or die $!;   # 書き出し時にエンコード

こうしておくと、$in から読んだ時点で中身はPerl内部のUnicode文字列になり、$out へ書いた瞬間に指定のエンコーディングへ戻ります。文字数カウントや正規表現も有効になるため、既存コードやロジックに手を入れる余地があるなら、この手段も選択肢に入れてみてください。

同じ考え方は、CGIの出力先である STDOUT にも必要です。decode した文字列をそのまま print すると、Wide character in print の警告が出たうえでUTF-8のバイト列がそのまま流れます。ページの charset がUTF-8ならたまたま動いてしまうのが厄介なところで、Shift_JISのページなら当然化けます。

binmode(STDOUT, ':encoding(utf-8)');                  # 出力側にもレイヤを張る
print "Content-Type: text/html; charset=UTF-8\n\n";   # ヘッダの charset と必ず揃える

レイヤを張らずに、出力直前で print encode('utf-8', $text); と書いても結果は同じです。どちらでも構いませんが、HTTPヘッダの charset= と実際に流すバイト列を一致させることだけは必須です。ここがズレると、せっかく直した変換処理が最後の最後で台無しになります。

なお、スクリプトの中に日本語のリテラルが直接書かれている場合は、ソースコードの保存形式まで揃える話になり、改修範囲が一気に広がります。本記事では踏み込みませんが、decode 済みの文字列とソース中のリテラルが噛み合わないという形で表面化するので、頭の片隅には置いておいてください16

4. テスト中に ? に化けていたら

Encodeshiftjis はJIS X 0201とJIS X 0208だけの「規格通りのShift_JIS」で、Windowsが使ってきたNEC・IBM拡張文字を含みません17。①②③ や ㈱㈲ のような機種依存文字は、既定では ? などの代替文字に置き換わります18

日本の業務データが相手なら cp932(別名 windows-31j)を選んでください。移行中やデータテスト時に from_to($str, 'cp932', 'euc-jp', Encode::FB_CROAK) のようにCHECKを明示して、変換できない文字を見つけたらその場でエラーを出しておくと検出が容易になります。

5. 撤去する(消す前にやること)

動作確認が完了したら jcode.pl を公開ディレクトリから撤去します。ここで一番怖いのは、「消したら誰かが困ったけど、誰が困ったのか分からない」という状態です。この手の資産は利用者が把握できていないことが多いので、いきなり rm せずに段階を踏んでください。

  1. バックアップを取る(パーミッションとタイムスタンプごと)
  2. アクセスログで利用実績を確認する(対象CGIが直近どれくらい叩かれているか。人間のアクセスなのかクローラなのかも見る)
  3. 利用者に停止予告を出す(分かる範囲で。イントラなら全体周知)
  4. まずアクセスを遮断して様子を見る(数週間、問い合わせが来ないか観測する)
  5. 問題がなければ削除する(切り戻せるようバックアップは残しておく)

事情があってすぐにファイルを削除できない場合も、4の遮断だけは先に済ませてください。ソースコードが丸見えのまま放置するより、遥かにマシです。
その上で「残す」という判断になった場合は、口頭で終わらせずに三つだけ書き残してください。誰の判断で残すのかいつ再評価するのかそれまでの代替策は何かの三点です。期限を切らないリスク受容は、結局のところ放置と同じです。

遮断設定を書くときのポイントは、jcode.pl という名前だけを対象にしないことです。<Files> 自体は *? を書けるのですが、<Files "jcode.pl"> のようにファイル名をそのまま置くと完全一致になり、前述の jcode.pl.bakjcode.pl.old を塞げません。一番危ないファイルが素通りしてしまいます。FilesMatch で前方一致にしてください。

# .htaccess での応急処置例(Apache 2.4以降)
# (?i) で大文字小文字を無視し、jcode.pl.bak や Jcode.pm もまとめて塞ぐ
<FilesMatch "(?i)^jcode\.">
    Require all denied
</FilesMatch>
# Apache 2.2以前の場合はこちらの記法
<FilesMatch "(?i)^jcode\.">
    Order allow,deny
    Deny from all
</FilesMatch>

老舗レンタルサーバでは今も2.2系の設定が残っていることがあります。落とし穴が二つ。

一つ目は、2.2に Require all denied を書くと「効かない」のではなく500エラーになることです。2.2の Require は認証と組み合わせて使う前提のディレクティブなので、AuthType が設定されていない状態では configuration error: couldn't perform authentication. AuthType not set! としてリクエストが失敗します。ただしこれが起きるのは <FilesMatch> にマッチしたファイルへのアクセス時だけで、403ではなく500という不格好な形にはなるものの、遮断そのものは結果的に成立します。

二つ目、AllowOverride の許可。.htaccess に書いたディレクティブがそのディレクトリで許可されていないと、Apacheは .htaccess の解析自体に失敗し、配下の全リクエストが500になります。ここで見落としやすいのが、中身のディレクティブではなく器の <FilesMatch> 自身が AllowOverride All を要求するという点です19。つまり AuthConfig だけ許可されていても、この遮断は書けません。ファイル遮断のつもりで書いた数行で、サイト一区画をまるごと落とすことになりかねません。

.htaccess を設置したら、必ずその場で自分でブラウザからアクセスして、正常なページが表示されることと対象ファイルが403になることの両方を確認してください。

# Nginx の場合(.htaccess は読まれないのでサーバ設定側で塞ぐ)
# 末尾を $ で固定すると /jcode.pl.bak が素通りするので、末尾は縛らない
location ~* /jcode\. {
    deny all;
}

Nginxで気をつけたいのは書く位置です。正規表現の location は設定ファイルに登場した順で先勝ちになるため、location ~ \.pl$ { fastcgi_pass ...; } のようなCGI用のブロックが上にあると、そちらが先に拾ってしまって遮断が効きません。この deny ブロックは必ずCGI用ブロックより手前に置いてください。

周辺の脆弱性にも注意

「先に結論」の最後で予告した「その先」が、ここです。

jcode.pl 自体は文字コード変換ライブラリであり、それ単体が脆弱性というわけではありません。しかし jcode.plEncode に置き換える作業は、$str$to といった変数が「どこから来て、どこで使われるか」というデータの流れを一つずつ追いかける作業でもあります。この追跡は、実はもっと危険なコードを見つける絶好の機会でもあります。

これは机上の心配ではありません。実際、jcode.pl を組み込んだ国産のアクセスログ解析CGIにクロスサイトスクリプティングの脆弱性が見つかり、JVN経由でCVEが採番された例があります(CVE-2008-4663 / JVNDB-2008-000043)20脆弱なのは jcode.pl ではなく、それを使っていたCGIの側です。jcode.pl はあくまで、そういう年代のコードがそこにあることを示す目印にすぎません。

具体的には、次のような脆弱性が同じ現場で一緒に見つかります。

最優先で見るべき「2引数 open() によるコマンドインジェクション」

追いかけている変数が、そのまま open(MAIL, "|/usr/sbin/sendmail $to") のような2引数 open に渡っていないか確認してください。文字列の先頭や末尾に | が来ると、Perlはそれをシェル経由のコマンド実行として解釈します。$to がユーザ入力で汚染されていれば、文字化け対策どころではない任意コマンド実行の脆弱性(CWE-78)に直結します。jcode.pl 由来のデータ破損が「条件次第で表面化する」リスクだったのに対し、こちらは即RCE(Remote Code Execution、リモートコード実行)級です。

修正は、コマンド名と引数を別々の要素として並べるリスト形式の open への置き換えが基本です。

# Before(シェル経由。$to が汚染されると任意コマンド実行)
open(MAIL, "|/usr/sbin/sendmail $to");

# After(コマンド名と引数を別の要素として渡す)
open(my $fh, '|-', '/usr/sbin/sendmail', $to) or die $!;

これはPerl 5.6.0で導入された3引数版 open(モードとファイル名を分けて渡す書き方)そのものではなく、パイプの相手を単一の文字列ではなくリストとして渡す書き方で、Perl 5.8.0以降はこの形式が使われるとシェルを経由せず直接execされるようになります21。これなら $to がシェルのメタ文字として解釈されることはありません。

ただしこれで終わりではありません。リスト形式では $to は空白を含んでいても引数1個ぶんにしかならないため、複数のオプションを流し込むことはできません。それでも $to-OQueueDirectory=/tmp のように - で始まる値だと、宛先ではなく sendmail 自身のオプションとして解釈されてしまいます。シェルを外しても、コマンドへのオプション注入(引数インジェクション、CWE-88)は残るわけです。

対策は、オプションの終わりを示す -- を挟んだうえで、$to がメールアドレスとして妥当な形式かどうかを別途バリデーションすることです。-- を解釈するかはMTA側の実装次第なので、使っているコマンドのmanで確認してください。

open(my $fh, '|-', '/usr/sbin/sendmail', '--', $to) or die $!;

引数インジェクションが実際にRCEまで至った例としては、PHPMailerの脆弱性(CVE-2016-10033)が有名です22。これはPHPかつシェル経由の事例ですが、sendmail にユーザ由来の文字列を渡すという構造そのものは言語を問いません。PerlのCGI側でも、configdir パラメータに混ぜたシェルのメタ文字がそのまま実行されて任意コマンド実行に至ったAWStatsの脆弱性(CVE-2005-0116)23のような実例があります。

そのほか、同じ現場で一緒に出てくるもの

  • Shift_JISの2バイト目起因によるエスケープ・フィルタのすり抜け(俗に言う「ダメ文字」問題)
    Shift_JISの2バイト目の範囲は 0x40〜0x7E と 0x80〜0xFC で、ここには \(0x5C)が含まれます2495 5C)や 83 5C)が有名どころです。攻撃者が「リードバイト+トレイルバイト範囲外の '」という不完全なマルチバイト列(たとえば 81 27)を送ると、マルチバイトを考慮しないエスケープ処理はクォートの前に \ を挿入して 81 5C 27 にします。ところがこれをShift_JISとして解釈すると 81 5C が1文字()として成立するため、エスケープ用に足した \ が文字の一部として消費され、' が生のクォートとして残ります
    SQLインジェクションやXSS対策のバイパスとして実際に悪用されてきた、文字コード起因のクラシックな脆弱性パターンです。MySQLが mysql_real_escape_string() でSJISなどのマルチバイトを正しく扱えずSQLインジェクションを許した脆弱性(CVE-2006-2753)25は、その代表例です。Encode へ移行した後も、1バイト単位で文字列を処理している箇所が残っていないか確認してください。前述の「decode してUnicode文字列として扱う」方針は、この手のバグを構造的に避けられるという意味でも効きます。
  • 入力値のタグ無害化・エスケープ処理の欠如
    文字コードの変換だけ手直しして、入力値のサニタイズ処理自体は当時のまま手つかず、というケースはよくあります。
  • CSRF(Cross-Site Request Forgery、クロスサイトリクエストフォージェリ)対策の欠如
    脆弱性自体は2000年代前半の当時から存在していましたが、概念として広く認知され対策が「当たり前」になる前に書かれた実装なので、講じられていないことが多いです。

【老人会コラム】2引数 open しかなかった時代
3引数版の open(FILEHANDLE, MODE, EXPR) がPerlに追加されたのはPerl 5.6.0(2000年リリース)からです26。前述のとおり、パイプの相手をリスト形式で渡してシェルを経由しない呼び出しができるようになったのは、さらに後のPerl 5.8.0からです。jcode.pl が書かれたPerl 4時代(1990年代前半)には、そもそも2引数以外の選択肢自体が存在しませんでした。当時のコードに2引数 open が大量に残っているのは行儀が悪かったからではなく、単純に「その頃はそれが唯一の書き方だった」というだけの話です。

せっかく追いかけたデータの流れがあるのですから、文字コード変換の書き換えで手を止めず、処理全体(=スクリプト全体)まで見てしまうのが(結果的に)お得です。

おわりに

理想を言えば jcode.pl を公開ディレクトリから跡形もなく消し去りたいところですが、現場の事情がそれを許さないこともあります。

とはいえ、本当に価値があるのは jcode.pl の置き換えそのものより、そのために $str$to の流れを一つずつたどった、その過程の方かもしれません。jcode.pl は、現場の「誰も触ってこなかったコード」を強制的に読み直させてくれるキッカケになります。たどった先に open() の2引数パイプが潜んでいたら、それは静的解析やセキュリティ診断が長らく行われてこなかった証でもあります。将来世代へのツケにせず、丁寧に潰して、整理しておく方がずっとマシです。

なお、jcode.pl が出てくるほど年季の入った現場であれば、これを機に言語やフレームワークも含めた棚卸しを検討する価値はあります。Shift_JISやEUC-JPでの相互変換を続けるのではなく、内部表現をUTF-8に統一する方向に寄せていくのも視野に入れてよいでしょう。jcode.pl の置き換えとは別のもっと大きな意思決定ではありますが、検討するキッカケとしていただければ幸いです。
その移行先がPerlのままでも、まったく構いません。古いのは jcode.pl であって、Perl本体ではないからです。今も現役で開発が続いていて、2026年7月には安定版の5.44.0が出ています27

そしてPerlに縁のない現場の方にとっても、これは他人事ではありません。
誰も仕様を説明できないまま動き続け、依存管理の仕組みにも載らず、消そうにも影響範囲が分からない。そういうモノは、言語やクラウドを問わずどこの現場にも一つは埋まっているものです。

  • 業務のど真ん中で今日も回っている .xlsm のマクロ
  • 手でコピーされて node_modules に居座っている .js
  • 誰も中身を知らないまま叩かれ続ける .bat
  • FROM 行が数年前のタグで止まったままのDockerfile

あなたの現場の jcode.pl は、何ですか?
その名前が思い浮かんだなら、この記事の役目は果たせたことになります。


お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。

  1. 同時期に登場した nkfNetwork Kanji Filter、初版1987年、作者は市川至氏)という変換ツールも、Perlに限らず広く使われていました。こちらは外部コマンドとして呼び出す形なので、Perlのコード内で完結する jcode.pl とは住み分けができていました。現在もLinuxディストリビューションのパッケージとして各所で現役ですが、上流の最新版は2.1.5(2018年12月)で止まっています。

  2. Jcode - Japanese Charset Handler (MetaCPAN)。「If the perl version is 5.8.1, Jcode acts as a wrapper to Encode, the standard charset handler module for Perl 5.8 or later.」と記載されている。BUGSにも「For perl is 5.8.1 or later, Jcode acts as a wrapper to Encode. Meaning Jcode is subject to bugs therein.」とある。

  3. perl5420delta - Apostrophe as a global name separator can be disabled。「This was deprecated in Perl 5.38 and removed as scheduled in perl 5.41.3, but after some discussion has been reinstated by default.」「enabled by default, but is disabled from the 5.41 feature bundle onwards」と記載されている。

  4. この時代のフォームメールCGIがどれほど無防備だったかは、FormMail.pl 1.6以前が recipientmessage パラメータを書き換えるだけで匿名メール送信(スパムの踏み台)に使えてしまった脆弱性(CVE-2001-0357)からもうかがえます。

  5. perl5260delta - Removal of the current directory (".") from @INC。「Starting with v5.26, "." is always removed by default, not just under tainting.」とあり、5.26より前でも taint モード(perl -T)では . が既に除外されていたことが読み取れる。-T 付きで動かしていたCGIは、5.26を待たずに同じエラーに出会っていた可能性がある。

  6. CVE-2016-1238 (NVD)。「do not properly remove . (period) characters from the end of the includes directory array, which might allow local users to gain privileges via a Trojan horse module under the current working directory.」と記載されている。なお、このCVEが直接対象としているのは cpanproveenc2xs などPerl同梱スクリプト25本であり、@INC. 問題そのものを網羅したエントリではない。

  7. この @INC contains: という文言は、Perl 5.38.0で @INC entries checked: に変更されている(perl5380delta - Changes to Existing Diagnostics)。同deltaも qr/\@INC contains:/ ではなく qr/\@INC[ \w]+:/ で書くよう勧めているので、ログを検索するなら文言差を吸収するか、Can't locate jcode.pl の側で引っ掛けるほうが確実。

  8. perldiag - do "%s" failed, '.' is no longer in @INC; did you mean do "./%s"?。警告カテゴリは deprecated::dot_in_inc。「Undefined subroutine &%s called」も同じperldiagに掲載されている。

  9. perl5380delta - Deprecation warnings now have specific subcategories。警告カテゴリ一覧に deprecated::apostrophe_as_package_separator が挙げられている。警告文そのものは Perl/perl5 issue #22145 に Perl 5.38.2 での実例が報告されている。

  10. Encode の Makefile.PL (MetaCPAN)。冒頭に use 5.007003; と記載されている。

  11. Encode::Guess - Guesses encoding from data (perldoc)。CAVEATSに「Do not mix national standard encodings and the corresponding vendor encodings.」と明記されている。

  12. Jcode.pm のSYNOPSISに Jcode::convert(\$str, $ocode, $icode, "z"); とあり、同関数は「100% upper-compatible with jcode::convert()」と明記されている(Jcode - Japanese Charset Handler (MetaCPAN))。この第4引数のオプションは jcode.pl 由来。

  13. Encode の配布物には Encode::JP::H2Z というモジュールが同梱されているが、Encode 本体のドキュメントに公開インタフェースとしての記載がなく、EUC-JPのバイト列を前提とした内部実装になっている。移行先として当てにするものではない。

  14. Unicode::Normalize (perldoc)。Perl 5.8からのコア同梱モジュール。NFKC は互換分解のあとに正準合成をかける形式なので、半角カナ以外の互換文字(丸数字、全角英数など)もまとめて変換される。また正準合成まで行うため、ガ は濁点が結合した (U+30AC)になる。濁点を分離した分解形(NFD)を前提にしているデータとは別物になる点にも注意。

  15. よく似た :utf8 というレイヤもあるが、こちらは使わない。PerlIO に「does not translate or validate byte sequences」「CAUTION: Do not use this layer to translate from UTF-8 bytes, as invalid UTF-8 or binary data will result in malformed Perl strings.」とあり、検証を行う :encoding(UTF-8)(ハイフンが significant と明記されている)のほうが推奨されている。

  16. ソースをUTF-8で保存したうえで先頭に use utf8; を宣言すると、ソース中のリテラルも「文字列」として扱われるようになる(utf8 (perldoc))。これが無いと decode 済みの文字列とリテラルが「文字列」と「バイト列」という別物になり、$text =~ /日本語/ のような正規表現がエラーも警告も出さずにマッチしなくなる。ただし use utf8; は宣言したファイル全体に効くため、既存コードに後から入れる場合は影響範囲の確認が必須。

  17. Encode::Supported - Microsoft-related naming mess (perldoc)。「The official Shift_JIS includes only JIS X 0201 and JIS X 0208 character sets, while Microsoft has always used Shift_JIS to encode a wider character repertoire.」「Encode separately supports Shift_JIS and cp932.」と明記されている。

  18. Encode - Handling Malformed Data (perldoc)。「Without CHECK, Encode::FB_DEFAULT ( == 0) is assumed.」「If CHECK is 0, encoding and decoding replace any malformed character with a substitution character. When you encode, SUBCHAR is used.」および FB_CROAK について「If CHECK is 1, methods immediately die with an error message.」と記載されている。

  19. <FilesMatch> の Override は Allcore - FilesMatch (Apache 2.4))。中身の Require 単体は AuthConfigmod_authz_core - Require)、2.2系の Order / DenyLimit だが、いずれも <FilesMatch> で囲む以上は All が前提になる。共用サーバでは AllowOverride の設定値をコントロールパネルや事業者のマニュアルで確認してから書き始めるのが安全。

  20. JVNDB-2008-000043 / CVE-2008-4663 (NVD)Jcode.pm 版についても同日(2008年7月23日)に JVNDB-2008-000044 として公表されている。ただしCVE番号を明記しているのはNVD側で、NVDの記述が「(1) jcode.pl and (2) Jcode.pm」として両方を同一CVEで扱っている。

  21. perlipc - Safe Pipe Opens。「Since Perl 5.8.0, you can also use the list form of open for pipes.」と明記されている。

  22. CVE-2016-10033 (NVD)。CWE-88(引数インジェクション)に分類され、CVSS 3.1で9.8 CRITICAL。CISAの「Known Exploited Vulnerabilities Catalog」(実際に悪用が確認された脆弱性のカタログ)にも登録されている。

  23. CVE-2005-0116 (CVE Record)。「AWStats 6.1, and other versions before 6.3, allows remote attackers to execute arbitrary commands via shell metacharacters in the configdir parameter」と記載されている。

  24. Shift_JISの2バイト目(trail byte)の範囲については、Microsoftの _ismbbtrail に「in code page 932 only, valid ranges are 0x40 to 0x7E and 0x80 to 0xFC」と記載がある。この範囲外なので、'(0x27)や "(0x22)が2バイト目に現れることはない。本文のバイト値はいずれもPerl 5.38.2の Encode で実際に確認した( = 95 5C = 83 5C81 5C = U+2015 )。

  25. CVE-2006-2753 (NVD)。「via crafted multibyte encodings in character sets such as SJIS, BIG5, and GBK, which are not properly handled when the mysql_real_escape function is used to escape the input.」と記載されている(CVE本文では関数名が mysql_real_escape と省略されているが、実際のC APIは mysql_real_escape_string())。

  26. perl56delta - open() with more than two arguments

  27. Perl v5.44.0 is now available! (perl5-porters)

7
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
7
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?