【PHP】ポートチェックを利用したパクリ・荒らし・スパム対策

「マッシュアップサイト」「キュレーションサイト」といった呼称もありますが、その実態はただの「パクリサイト」という事はよくあります。特に悪質なのは、プログラムによるクローリング⇒スクレイピングで、自動的に大量のコンテンツをパクりまくる手法だと思います。

様々な対策がありますが、この記事では「ポートチェック」という方法を紹介してみたいと思います。掲示板荒らしやコメントスパムの対策にも応用可能な方法です。

--------------------------------------------------------------------------------

## 対象としている人

- コンテンツのパクリ・掲示板荒らし・コメントスパム等に悩んでいる方。
- ソースコードのコピペではなく、自分で創意工夫してみたい方。
	- 記事内で紹介しているコードは、**コピペ用ではありませんので、趣旨と関係ない部分は意図的に省略しています。**
- この方法のデメリットや問題点まで理解できる方。

そのものズバリの正解としてではなく、「こんなやり方もある」というアイデアや発想の例してご参考ください。

--------------------------------------------------------------------------------

## 考え方

- スクレイピング用のプログラム(パクリプログラム)は、Webサーバ等を兼用しているサーバー上で動いている事も多い。
- 掲示板荒らしやコメントスパムはProxyやVPNを通している事も多い。

**普通のパソコンやスマホでは開いていない筈の[ポート](https://ja.wikipedia.org/wiki/%E3%83%9D%E3%83%BC%E3%83%88%E7%95%AA%E5%8F%B7)が開いている**事が多いので、そこをチェックして対策しようという考え方になります。

--------------------------------------------------------------------------------

## チェックするポート

- 80：HTTP
- 443：HTTPS

便宜上、今回は上記2つだけを対象に話を進めていきます。PHPのコードとしては、以下のような配列で扱う事にします。

```php
//-- ポート番号 => ソケットトランスポート
$portAry = [
    80 => 'tcp',     // HTTP
    443 => 'tls',    // HTTPSやVPN等

    //-- ProxyやVPN
    // 1080 => 'tls',    // Proxy(SOCKS Proxy)
    // 3128 => 'tcp',    // Proxy(Squid)
    // 8080 => 'tcp',    // Proxy
    // 1723 => 'tcp',    // VPN(PPTP)

	//-- その他
    // 22 => 'tcp',      // SSH
];
```

- `tcp` や `tls` 等のソケットトランスポートについて
	- [サポートされるソケットトランスポートのリスト](http://php.net/manual/ja/transports.inet.php)
	- 使用できるトランスポートは、[stream_get_transports()](http://php.net/manual/ja/function.stream-get-transports.php)を実行すると簡単に確認できます。

```php
print_r(stream_get_transports());
```

- 掲示板あらし対策として利用する場合は、ProxyやVPNでよく使われるポートを確認しても良いと思います。
	- 実際、大手掲示板や大手ブログ等では、書き込み処理時にポートチェックを行っているところもあります。
	- ただし、上記のポート番号はあくまでもデフォルト値であり、簡単に変える事もできます。
	- 22はSSH用のポートですが、Open Proxy(公開プロキシ)では開いている事も多いです。
<br><br>
- [TCPやUDPにおけるポート番号の一覧(Wikipedia)](https://ja.wikipedia.org/wiki/TCP%E3%82%84UDP%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E3%83%9D%E3%83%BC%E3%83%88%E7%95%AA%E5%8F%B7%E3%81%AE%E4%B8%80%E8%A6%A7)

--------------------------------------------------------------------------------

## ソースコード

```php:example1.php
function isDenyPort($portAry, $timeout = 2)
{
    //-- アクセス元のIPアドレス
    $ip = getenv('REMOTE_ADDR');

    //-- ポートスキャン(Vanilla Scan)開始
    foreach ($portAry as $port => $transport) {
        //-- ポートが開いている
        if ($sock = @stream_socket_client("{$transport}://{$ip}:{$port}", $errNo, $errStr, $timeout)) {
            fclose($sock);
            return true;
        }
    }

    //-- ポートが開いていない
    return false;
}
```

[stream_socket_client()](http://php.net/manual/ja/function.stream-socket-client.php)でソケット接続を試みるだけの簡単な処理で、Vanilla ScanやTCP Scanと呼ばれる方法になります。

より高度なスキャン方法としては、[nmap](https://nmap.org/)が定番ですが、この記事では簡単な紹介のみとします。

- [Zenmap](https://nmap.org/zenmap/)
	- Linuxのポートスキャンコマンド`nmap`(Network Mapper)をGUIで簡単に実行できるツールです。Windows版やMacintosh版もあります。

--------------------------------------------------------------------------------

### 使い方

```php
var_dump(isDenyPort($portAry));    // true or false
```

`true` が返ってきたら、「ポートが開いているので怪しい」という事になります。

--------------------------------------------------------------------------------

## 問題点1

この方法には、以下のような問題点があります。

1. アクセスの度に、アクセス元の全てのポートをチェックしていると、処理に時間がかかってしまう。
1. Googleのクローラー等、弾きたくないものまで弾いてしまう可能性がある。
1. 悪意のない一般ユーザーでも、自宅サーバー用に80番ポート等が開いている場合がある。

以下では1について、対策を考えてみる事にします。

--------------------------------------------------------------------------------

### 改善案1.指定確率で実行できるようにする

```php
//-- 確率30%で { } 内を実行する
if (mt_rand(1, 100) + 30 > 100) { }
```

1以上100以下の整数をランダムに発生させ、その値に実行確率(上記では30)を加えた値が、100より大きければ実行・100以下なら実行しない…という考え方です。

乱数の生成方法は色々とありますが、今回は手っ取り早い方法として、[mt_rand()](http://php.net/manual/ja/function.mt-rand.php)を使用する事にします。

--------------------------------------------------------------------------------

### 改善案2.ブラックリストを自動生成しチェック処理を高速化する

リストを作る方法としては、下記のようなフォーマットの配列を、[serialize()](http://php.net/manual/ja/function.serialize.php)でテキスト化して保存し、[unserialize()](http://php.net/manual/ja/function.unserialize.php)で元に戻して利用する…という方法を取ることにします。

```php
$denyAry = [
    'IPアドレス' => 'ポート番号',
    'IPアドレス' => 'ポート番号',
];

$data = serialize($denyAry);
unserialize($data);
```

データのフォーマットとしては、他にも以下のようなものがあります。

- データベースを利用する
	- 今回のような用途では、気軽にファイル感覚で使えるSQLiteがオススメです。
- CSVやTSVを利用する
	- 人間の目から見ても分かりやすいフォーマットです。
- JSON形式を利用する
	- 他言語とのデータの互換性や再利用性が高いフォーマットです。
	- [json_encode](http://php.net/manual/ja/function.json-encode.php) / [json_decode](http://php.net/manual/ja/function.json-decode.php)

※DBではなくファイルを利用する場合は、本来は[ファイルロック処理](https://www.google.co.jp/search?q=PHP+%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%AD%E3%83%83%E3%82%AF&ie=utf-8&oe=utf-8)をもう少し厳密に行った方が良いのですが、今回はその部分の説明は省略します。

--------------------------------------------------------------------------------

## 改良したソースコード

対策1と2を取り込んでみたコードの例です。

```php:example2.php
function isDenyPort($portAry, $timeout = 2, $probability = 100, $path = null)
{
    //-- ブラックリスト
    $denyAry = (isset($path) && ($data = file_get_contents($path))) ? unserialize($data) : [];

    //-- アクセス元のIPアドレス
    $ip = getenv('REMOTE_ADDR');

    //-- ブラックリストに登録済み
    if (isset($denyAry[$ip])) { return true; }

    //-- 以下のポートチェックは指定した確率で実行する
    if (mt_rand(1, 100) + $probability <= 100) { return false; }

    //-- ポートスキャン開始
    foreach ($portAry as $port => $transport) {
        //-- ポートが開いていないので次のポートをチェックする
        if (!$sock = @stream_socket_client("{$transport}://{$ip}:{$port}", $errNo, $errStr, $timeout)) {
            continue;
        }

        //-- ポートが開いている事が分かったので接続を閉じる
        fclose($sock);

        //-- ブラックリストに加えて保存する
        if (isset($path)) {
            $denyAry[$ip] = $port;
            file_put_contents($path, serialize($denyAry), LOCK_EX);
        }

        //-- ポートが開いている
        return true;
    }

    //-- ポートが開いていない
    return false;
}
```

--------------------------------------------------------------------------------

### 使い方

```php
//-- タイムアウト1秒・実行確率30%・ブラックリストを'./check.txt'へ自動生成
var_dump(isDenyPort($portAry, 1, 30, './check.txt'));    // true or false
```

--------------------------------------------------------------------------------

## 問題点2

掲示板の荒らしやコメントスパム対策であれば、以下のような流れでポートチェック処理を加えると良いと思います。

```php
//-- 1.書き込み内容のチェック処理(validation)

//-- 2.ポートチェック
if (isDenyPort()) { }

//-- 3.問題がなければ書き込み処理
```

しかし、コンテンツのパクリ対策として利用する場合は…

```php
//-- 1.ポートチェック
if (isDenyPort()) { }

//-- 2.コンテンツの出力処理
```

…という流れにしてしまうと、ブラックリストにはないIPアドレスに対してポートチェック処理が実行された場合、コンテンツの表示が始まるまでに時間がかかってしまいます。

--------------------------------------------------------------------------------

### 改善案3.ブラックリストの生成処理を非同期的に行う

ブラックリストを生成後、1×1ピクセルの透過GIFを出力するPHPを「generate_blacklist.php」とします。

```php:generate_blacklist.php
<?php
generateBlacklist($path, $portAry);    // ブラックリストを生成
outputImageGif();                      // 1×1ピクセルの透過GIFを出力する
exit;

function generateBlacklist($path, $portAry, $timeout = 2)
{
    //-- ブラックリスト
    $denyAry = ($data = file_get_contents($path)) ? unserialize($data) : [];

    //-- アクセス元のIPアドレス
    $ip = getenv('REMOTE_ADDR');

    //-- ブラックリストに登録済み
    if (isset($denyAry[$ip])) { return true; }

    //-- ポートスキャン開始
    foreach ($portAry as $port => $transport) {
        //-- ポートが開いている
        if ($sock = @stream_socket_client("{$transport}://{$ip}:{$port}", $errNo, $errStr, $timeout)) {
            fclose($sock);
            $denyAry[$ip] = $port;
            return (bool) file_put_contents($path, serialize($denyAry), LOCK_EX);
        }
    }

    //-- ポートが開いていない
    return false;
}

function outputImageGif()
{
    header('Content-Type: image/gif');
    echo base64_decode('R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7');
    exit;
}
```

コンテンツの表示時は、ブラックリストのチェックのみを行い、上記で作成した「generate_blacklist.php」は、「index.php」出力時に`<img>`タグを利用して実行するようにします。

```php:index.php
<?php
//-- 1.ブラックリストによるチェック
if (isDenyPort()) { }

//-- 2.コンテンツの出力処理
echo 'コンテンツ';
echo '<img src="./generate_blacklist.php" width="1" height="1">';
exit;

function isDenyPort($path)
{
    //-- ブラックリスト
    if (!$data = file_get_contents($path)) { return false; }
    if (!$denyAry = unserialize($data)) { return false; }

    //-- ブラックリストに登録済みかどうか
    return isset($denyAry[getenv('REMOTE_ADDR')]);
}
```

このようにすれば、「generate_blacklist.php」によるポートチェック処理は「index.php」の出力処理とは無関係に実行されるため、コンテンツの表示までに時間がかかる事はなくなります。

上記のような`<img>`タグや`<iframe>`タグを用いて本体とは別にPHPを実行させる方法は、`<img>`タグや`<iframe>`タグへの**リクエストが発生しないと実行されない**など**正確性には欠けます**が、非常にお手軽な方法です。

以下のようにすると、cronのような定期処理を擬似的に行ったりする事もできます。

```php
//-- 何かしらの処理後にファイルの更新日時を更新
touch($path);

//-- 前回の処理から1時間経っていたら { } 内を実行
if ($_SERVER['REQUEST_TIME'] - filemtime($path) >= 3600) { }
```

--------------------------------------------------------------------------------

## まとめ

スパムやスクレイピングで使われるプログラムは、一般的なWebブラウザと比較すると、様々な点で挙動が異なる場合があります。

1. 環境変数UserAgentを吐かない・もしくは一般的なUserAgentとは異なる。
	- その他、環境変数が特徴的。(Proxy経由のアクセスを示す情報を吐く・HTTP_ACCEPT_LANGUAGEに日本を含まない等)
1. クッキーを処理しない。
1. JavaScriptを実行しない。
1. `<iframe src="">` タグの `src=""` 内パスへのリクエストが発生しない。
1. 通常のWebブラウザでは発生しない筈のリクエストが発生する。(いわゆるハニーポットをしかける)
1. リクエストが一定時間毎に発生している。
1. IPアドレスが日本国外。
1. 書き込みリクエストに日本語が含まれない。

しかし、例えば1は、環境変数を日本のChromeと全く同じものにする事はできますし、2～4は、一般的なWebブラウザの動作をフックして自動処理させれば、見分けは付かなくなります。

- 完璧な対策は存在しない
- 対策処理はいたちごっこ

ですので、何かしらの方法で弾いた場合は、弾いた事を相手に気づかせないのがコツだと思います。

例えば、今回の方法であらし行為に対処する場合、「変なポートが開いてるから書き込めません」とエラーを表示するよりも…

- 「書き込めました」と表示して、実際には書き込めていない。
- その人の環境からは書き込めたように見えるが、他の人からは書き込みは見えない。

…とするのが、賢い対策なのではないかと思います。

サイト運営には、パクリ行為やあらし・スパム行為は付き物です。大事なのは、広く浅く色んな知識を身に付けておき、問題が発生した際に柔軟に対応できるアイデアや発想を養っておく事ではないかと思います。

--------------------------------------------------------------------------------

## オマケ：美はシンプルさに宿る

とあるプログラマさんの言葉で "[Beauty Is in Simplicity](https://www.google.com/search?q=Beauty+Is+in+Simplicity+code&ie=utf-8&oe=utf-8)" というのがあります。日本語では「[美はシンプルさに宿る](https://www.google.com/search?q=%E7%BE%8E%E3%81%AF%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%81%95%E3%81%AB%E5%AE%BF%E3%82%8B&ie=utf-8&oe=utf-8)」と訳される事が多いようです。

分かりやすく言うと「一目見て何をしているのかすぐ分かるコードは良いコード」という事です。個人的に**至言**だと思っているのですが、記事内「example2.php」の `isDenyPort()` という関数は…

1. ブラックリストのチェック
1. 指定確率で実行する
1. ポートスキャン
1. ブラックリストの更新

という事を1つの関数の中で行っており、「良いコード」とは言えないものです。例えばこの処理をクラスにし、4つのメソッドへ分離してみる等すると、良い勉強になるのではないかなと思います。

[phpDocumentor](https://www.google.com/search?q=phpDocumentor&ie=utf-8&oe=utf-8)に沿ったコメントを付ける事もお忘れなく！