HTTPクエリーを正しく受け取る
HTTPクエリーを正しく受け取ることは重要です。
これには、ふたつの意味があります。
- HTTPクエリーを破損なく受け入れる
- HTTPクエリーをバリデートし受け入れる
私が攻撃者であれば、例えば画像ファイルを偽装して、SQLやPHP、時には別のコードのインジェクションを試みます。
添付ファイルがディレクトリーに保存された後、そのファイルを実行してデータを盗み見るのです。
設計によっては、保存するディレクトリーすら攻撃者が任意で変更できてしまう場合もあります。
あるいは、クエリー文字列内に悪意あるコードを忍ばせ、クロスサイトスクリプティングを試みます。
もしかしたら、これはコードインジェクションにもつながるかも知れません。
何度か試して、悪用の目途が立ったなら、ユーザー情報を盗み出すかも知れません。
このようなことになれば一大事、これら脆弱性を開発段階から撲滅する努力が、開発者には求められています。
この記事では、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 *:/ | 大文字小文字を問わない | |
& | & | |
' | ' | |
< | ||
> | > |
コード例が必要か微妙なところですが、コード例は次の通りです。
<?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; } }
アップロードされたファイルを受け入れ検査し、png、jpeg、gif画像は変換し、その他のファイルは移動します。
この際、パーミッションを644に設定し、不必要な書き込みと、実行権限を剥奪します。
受け入れるファイルは、MIMEタイプと拡張子(または、元ファイル名の拡張子を示すtrue)の連想配列で指定します。
標準では、pdf、gif、jpg、png、svg(拡張子は.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);