fuka’s diary

A blog that shares my knowledge.

HTTPクエリーを正しく受け取る

HTTPクエリーを正しく受け取ることは重要です。
これには、ふたつの意味があります。

  • HTTPクエリーを破損なく受け入れる
  • HTTPクエリーをバリデートし受け入れる

私が攻撃者であれば、例えば画像ファイルを偽装して、SQLPHP、時には別のコードのインジェクションを試みます。
添付ファイルがディレクトリーに保存された後、そのファイルを実行してデータを盗み見るのです。
設計によっては、保存するディレクトリーすら攻撃者が任意で変更できてしまう場合もあります。

あるいは、クエリー文字列内に悪意あるコードを忍ばせ、クロスサイトスクリプティングを試みます。
もしかしたら、これはコードインジェクションにもつながるかも知れません。
何度か試して、悪用の目途が立ったなら、ユーザー情報を盗み出すかも知れません。

このようなことになれば一大事、これら脆弱性を開発段階から撲滅する努力が、開発者には求められています。
この記事では、HTTPクエリーを正しく受け入れる知識を共有します。

想定する攻撃手法

 ファイル名にディレクトリー構造を書き加え、非公開ディレクトリーにアクセスします。

  • コードインジェクション

 プログラム言語や、データベース、時にはHTTPヘッダーに悪意あるコードを挿入します。

 悪意あるコードを送信し、利用者にコードを(手動、自動を問わずに)実行させます。
 厳密には異なりますが、コードインジェクションと一部が重複した攻撃と言えるでしょう。

  • ファイル偽装

 添付ファイルの拡張子やファイルヘッダーを偽装して、悪意あるファイルを受け入れさせます。

対策

POST/GETメソッド

基本的にユーザーが指定したディレクトリー構造や、ファイル名を受け入れないことが鉄則です。
ましてや、文字列をそのまま受け入れて、そのまま表示、などもってのほかです。

クエリー文字列を受け入れる時は、次のように置き換えます。

対象 置き換え後 備考
/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/

また、次の文字列は受け入れません。

対象 備考
/^javascript *:/ 大文字小文字を問わない

実際のコード例は以下の通りです。

<?php
function get_query_strings($name, $get_mode = false){
    if($get_mode){
        if(! isset($_GET[$name])){return false;}
        $text = $_GET[$name];
    }else{
        if(! isset($_POST[$name])){return false;}
        $text = $_POST[$name];
    }
    $text = preg_replace('/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
    $ret = false;
    if(isset($text)){
        if(is_array($text)){
            $ret = [];
            $values = $text;
            foreach ($values as $value){
                if(preg_match('/^ *javascript *:/i', $value) !== 0){return false;}
                $ret[] = $value;
            }
        }else{
            if(preg_match('/^ *javascript *:/i', $text) !== 0){return false;}
            $ret = $text;
        }
    }
    return $ret;
}

PHPで文字列を出力する時は、次のように置き換えます。

対象 置き換え後 備考
/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/
/^javascript *:/ 大文字小文字を問わない
& &amp;
' &#039;
&lt;
> &gt;

コード例が必要か微妙なところですが、コード例は次の通りです。

<?php
function sanitize($text){
    $text = preg_replace('/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
    $text = preg_replace('/^ *javascript *:/i', '', $text);
    $text = htmlspecialchars($text);
    return $text;
}

おまけですが、一部のHTMLタグのみ残したい場合があるかも知れません。
この場合は、次のように記述すると良いでしょう。

<?php
$code = "<STRONG>強調</strong>\n<EM>強調</em>\n<img src='sample.jpg'>\n<script>alert('アラート');</script>\n";
$result = preg_replace_callback(
    '/<(\/*)(.+?)>/i',
    function($match){
        $tags = ['strong', 'em'];
        $result = $match[0];
        if(array_search(mb_strtolower($match[2]), $tags) === false){
            $result = htmlspecialchars($match[0]);
        }
        return $result;
    },
    $code
);
echo $result;

上記は<STRONG>...</strong>と<EM>...</em>だけを残して、他のタグをHTMLエンティティーへ置き換えて(エスケープ処理をして)います。
アルファベットは大文字、小文字を問いません。

ファイル

どこまでを保護対象とするのか、サービス構築時にこれを考えることは重要です。
サービス側は前述の「攻撃手法」への対策を行います。
ユーザーの保護は、各ユーザー環境のソフトウェアに任せましょう。

ファイルのアップロード処理は、どのように行われているのでしょうか。
流れを追ってみましょう。

①アップロードされたファイルは、一時ファイルとしてディレクトリーに格納される
この時、ファイル名は個別に与えられます。
元々のファイル名や拡張子は失われているので注意しましょう。

②アップロードされたファイルの情報を受け取る
ファイル自体ではなく、アップロードされたファイルのエラー情報、格納先、元のファイル名、ファイルサイズなどを受け取ります。

③アップロードされたファイルを見つける
どこに、どのようなファイル名で格納されているか調べます。

MIMEタイプによる受け入れ検査
受け入れて良いファイルフォーマットかを調べます。
しばしば、攻撃者は拡張子を偽装しますので、間違っても拡張子だけで判別しないようにしましょう。

⑤イメージファイルであれば、同一ファイルフォーマットへ変換
画像を再変換することで、画像内に仕組まれた悪意あるコードを削除します。
変換できないことによってファイル偽装を検出できます。

⑥新しい名前の生成
ファイル名(ディレクトリーのパス)が悪用される可能性を排除します。

⑦ファイルを新しい名前で移動
アップロードされたファイルを受け入れます。
この時、決してアップロードしたユーザーが、保存先ディレクトリーを指定できるようにしてはいけません。
ファイルの格納先は、サービス側が「絶対パス」で決定しましょう。

パーミッションを変更
Windowsでは馴染みが薄いですが、ファイルの読み・書き・実行権限を設定します。
実行権限を解除することで、コードとして実行されることがなくなります。
格納ディレクトリーのパーミッションも正しく設定しましょう。

以下は実際のコード例です。

アップロードされたファイルの情報を受け取り、成功したファイルの情報を取得します。

<?php
function get_file($name){
    if(isset($_FILES[$name])){
        $ret = false;
        if(is_array($_FILES[$name]['name'])){
            $ret = [];
            $files = $_FILES[$name];
            $errors = $files['error'];
            $pos = 0;
            foreach ($errors as $error){
                if($error === UPLOAD_ERR_OK){
                    $dict = [];
                    $dict['name'] = $files['name'][$pos];
                    $dict['size'] = $files['size'][$pos];
                    $dict['tmp_name'] = $files['tmp_name'][$pos];
                    $ret[] = $dict;
                }else{
                    return false;
                }
            }
        }else{
            $file = $_FILES[$name];
            $ret = [];
            if($file['error'] === UPLOAD_ERR_OK){
                $ret['name'] = $file['name'];
                $ret['size'] = $file['size'];
                $ret['tmp_name'] = $file['tmp_name'];
            }else{
                return false;
            }
        }
        return $ret;
    }else{
        return false;
    }
}

アップロードされたファイルを受け入れ検査し、pngjpeg、gif画像は変換し、その他のファイルは移動します。
この際、パーミッションを644に設定し、不必要な書き込みと、実行権限を剥奪します。
受け入れるファイルは、MIMEタイプと拡張子(または、元ファイル名の拡張子を示すtrue)の連想配列で指定します。
標準では、pdf、gif、jpg、pngsvg(拡張子は.svgと.svgz)、mp4を受け入れます。

<?php
function create_file_name($directory, $extension){
    while(true){
        $file = date("YmdHis").bin2hex(random_bytes(32)).'.'.$extension;
        if(! file_exists($directory.'/'.$file)){break;}
    }
    return $directory.'/'.$file;
}

$mime_types = [
    'application/javascript'=>['js'],
    'application/json'=>['json'],
    'application/msword'=>['doc'],
    'application/pdf'=>['pdf'],
    'application/vnd.ms-cab-compressed'=>['cab'],
    'application/vnd.ms-excel'=>['xls'],
    'application/vnd.ms-outlook'=>['msg'],
    'application/vnd.ms-powerpoint'=>['ppt'],
    'application/vnd.oasis.opendocument.spreadsheet'=>['ods'],
    'application/vnd.oasis.opendocument.text'=>['odt'],
    'application/vnd.openxmlformats-officedocument.presentationml.presentation'=>['xlsm', 'xlsx'],
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'=>['docm', 'docx'],
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document'=>['pptm', 'pptx'],
    'application/x-lzh-compressed'=>['lzh'],
    'application/xml'=>['xml'],
    'application/x-msaccess'=>['accdb', 'mdb'],
    'application/x-msdownload'=>['exe', 'msi'],
    'application/x-rar-compressed'=>['rar'],
    'application/x-shockwave-flash'=>['swf'],
    'application/zip'=>['3mf', 'zip'],
    'audio/mpeg'=>['mp3'],
    'image/bmp'=>['bmp'],
    'image/gif'=>['gif'],
    'image/jpeg'=>['jpe', 'jpeg', 'jpg'],
    'image/png'=>['png'],
    'image/svg+xml'=>['svg', 'svgz'],
    'image/tiff'=>['tif'],
    'image/tiff'=>['tiff'],
    'image/vnd.microsoft.icon'=>['ico'],
    'text/css'=>['css'],
    'text/html'=>['htm', 'html', 'odc', 'php'],
    'text/plain'=>['bas', 'bat', 'cls', 'csv', 'ini', 'php', 'txt', 'vbs'],
    'video/mp4'=>['mp4'],
    'video/quicktime'=>['mov', 'qt'],
    'video/x-flv'=>['flv'],
];

function move_upload_file($from, $original_name, $to, $mime_extension = null){
    global $mime_types;
    try{
        if(! is_uploaded_file($from)){throw new ErrorException('Upload file does not exist.');}
        if(is_null($mime_extension)){
            $mime_extension = [
                'application/pdf'=>'pdf',
                'image/gif'=>'gif',
                'image/jpeg'=>'jpg',
                'image/png'=>'png',
                'image/svg+xml'=>true,
                'video/mp4'=>'mp4',
            ];
        }
        $mime = mime_content_type($from);
        if(! isset($mime_types[$mime])){throw new ErrorException('MIME type is undefined.');}
        $reasonable = false;
        foreach ($mime_types[$mime] as $extension){
            if(strrpos($original_name, '.') === false){throw new ErrorException('File name has no extension.');}
            if(strcmp(substr($original_name, strrpos($original_name, '.') + 1), $extension) === 0){
                $reasonable = true;
                break;
            }
        }
        if(! $reasonable){throw new ErrorException('Extension mismatch.');}
        $file_move = false;
        if(! isset($mime_extension[$mime])){throw new ErrorException('Not an acceptable MIME type.');}
        $ext = $mime_extension[$mime];
        if($ext === true){
            $ext = $extension;
        }
        $file = create_file_name($to, $ext);
        if(strcmp($mime, 'image/png') === 0){
            $image = imagecreatefrompng($from);
            if(! imagepng($image, $file)){throw new ErrorException('Image export failed.');}
        }elseif(strcmp($mime, 'image/gif') === 0){
            $image = imagecreatefromgif($from);
            if(! imagegif($image, $file)){throw new ErrorException('Image export failed.');}
        }elseif(strcmp($mime, 'image/jpeg') === 0){
            $image = imagecreatefromjpeg($from);
            if(! imagejpeg($image, $file)){throw new ErrorException('Image export failed.');}
        }else{
            $file_move = true;
        }
        if($file_move){
            if(! move_uploaded_file($from, $file)){throw new ErrorException('Failed to move the uploaded file.');}
        }else{
            if(! unlink($from)){throw new ErrorException('Failed to delete temporary files.');}
        }
        if(! chmod($file, 0644)){throw new ErrorException('Failed to change permissions.');}
    }catch(\Throwable $e){
        @unlink($from);
        $file = false;
    }
    return $file;
}

使い方は以下のとおりです。
ここでは、png、jpg、gifのみ受け入れています。

<?php
$files = get_file('test');
$from = $files[0]['tmp_name'];
$original_name = $files[0]['name'];
$to = './upload';
$mime_list = [
    'image/gif'=>'gif',
    'image/jpeg'=>'jpg',
    'image/png'=>'png',
];
echo move_upload_file($from, $original_name, $to, $mime_list);