Zabbix5.4の認証処理をパスするセッションデータを生成する方法

Page content

こんにちは。

システムやミドルウェアのバージョンアップによって、これまで動作していたものが動かなくなるという経験をされた方は多いと思います。

今回は、運用保守業務としておこなっている、システム監視ソフトウェアZabbixから稼働レポートを出力する自作プログラムが、Zabbixのバージョンを ver4.2から2021年10月時点での最新版となる ver5.4にアップグレートしたことによって動作しなくなった時の話です。

バージョンアップにより、プログラムによるZabbixフロントエンドへの認証処理が失敗するようになってしまいました。 調査の結果、Zabbixのフロントエンドにおいて、ユーザのログイン状態を管理するセッション周りの実装がかわったことによるものとわかったので調査結果をまとめておきます。

APIによるデータ取得はできるけど、グラフ画像がダウンロードできなくなったよ、という方がいれば参考にしてください。

発生した事象

レポート出力プログラムでは、Zabbixのフロントエンドに対して、Zabbix API1 によるデータ取得と特定のリソースデータのグラフ画像を生成してくれるCGIプログラムを活用しています。 Zabbixでは、APIの呼び出しやCGIプログラムの呼び出しに対してユーザ認証を要求します。 認証情報の受け渡し方法は、APIとフロントエンドで微妙に異なるのですが、ver4.2ではAPIで取得した認証情報(セッションID)をCookieに流用することで認証をパスできていました2。 それが、ver5.4ではver4.2でのCookieでは認証がパスできなくなりました。

原因

調査の結果、下記の2つの問題が判明しました。

  1. Cookie中のセッション情報のデータ構造が単純なキーバリュー型からJSON形式の構造化データに変わっていた。
  2. ver5.4から、セッション情報の改ざんなどへのセキュリティ対策として秘密鍵による署名・暗号化処理が導入されていた。

直接の要因は、1のセッション情報のデータ構造の変更によるものでしたが、2のセキュリティ強化によって、API経由で取得したセッションIDだけでは、認証をパスするセッションデータを生成することができなくなっていました。

具体的には、ver4.2では以下に示すように、APIで取得した際に発行されるセッションIDをセットすれば問題ありませんでした。

zbx_sessionid = 'APIで取得したセッションID'

一方、ver5.4では以下に示すように、セッション情報が暗号化されていました。

zbx_session = 'eyJzZXNzaW9uaWQiOiIwMTIzNDU2ODkiLCJzaWduIjoiZXlKelpYTnphVzl1YVdRaU9pSXdNVEl6TkRVMk9Ea2lmUT09In0'

APIドキュメントを確認したところ、こちらはBase64エンコードされたJSONデータであることがわかりました3。 Base64でデコードすると以下のようになります。

{"sessionid":"012345689","sign":"eyJzZXNzaW9uaWQiOiIwMTIzNDU2ODkifQ=="}

セッションID以外に、“sign"と呼ばれるパラメータが必ず付与されるようになっていました。 signパラメータが含まれないJSONデータをBase64エンコードしてCookieに設定しても認証をパスすることができませんでした。 また、signパラメータに適当な値を設定しても当然ダメでした。 フロントエンドの認証をパスするためには、正しいsignパラメータとセッションIDを含むJSONデータを用意する必要があります。

対応方法

詳細については、後述するとして今回の問題への対応は下記の2つの方法のどちらかが考えられます。

  1. Webインタフェースによるユーザ認証をエミュレートし、生成されたCookieデータを利用する
  2. API経由で取得したセッションIDと、事前にデータベースから取得した暗号化鍵から署名を生成する

1の方法は、プログラム側でブラウザでおこなっているユーザ認証と同様のフローを再現し、署名情報が付与されたCookieをZabbixに生成してもらう方法です。

2の方法は、調査の結果判明した情報を元にプログラム側で署名生成機能を実装する方法です。

はじめは、2の方針で解決を試みようとしたのですが、今後のZabbix側の更新による影響やセキュリティ面も考慮すると2の方法は推奨されないという結論に至りました。 というのも、

  • 暗号化に必要なアルゴリズムの定義がソースコード中に定数定義されている
  • 暗号化に必要な鍵は、データベース中に保存されている

という点から、プログラム側で署名情報を生成する実装をおこなう場合、事前に対向先のZabbix側の暗号化アルゴリズムを確認したり、 暗号化に必要な機密情報を持ち出す必要が発生します。 Zabbixを管理している立場であればできなくもないですが、これらの情報を安易にシステム外に持ち出すのはよくないと考えます。 そのため、Zabbix APIで取得したセッションIDが流用できないため、二度手間となりますがZabbixの認証機能を用いてCookie情報を取得することをお勧めします。

以降の内容は、調査によって判明した内容から逆流するようにZabbix側の挙動を解析していったため、2の方針での検証結果となります。

signパラメータの正体

結論から言ってしまうと"sign"パラメータは、signパラメータを除いたJSONデータを暗号化して生成される署名情報でした。

つまり、{"sessionid":"012345689","sign":"aiueo12345"} というセッションデータがあった場合、signパラメータを取り除いた、 {"sessionid":"012345689"} というデータに対して署名を生成すると、その結果が aiueo12345 になるということです。

元となるデータから生成された署名を付与することで、以降送られてくるセッション情報を元に同様の署名を生成し、signパラメータと比較することでセッションが改ざんされていないかを検証しているようです。

そのため、Zabbix側ではセッション情報を更新する際、以下のような流れでセッション情報を更新しています。

1. 元となるセッションデータを {"sessionid":"012345689"} とする
2. 1のデータをアルゴリズムに従って暗号化する
3. 暗号化されたデータをsignパラメータの値として1のセッションデータに追加する
4. 追加されたセッションデータは、{"sessionid":"012345689","sign":"aiueo12345"} のようになる
5. 4のデータをBase64エンコードして、Cookieにセットする

セッションデータの検証は逆の手順となります。

1. Cookieにセットされた暗号データをBase64デコードして平文に変換する
2. JSONデータからsignパラメータを取り除く
3. 2のデータを暗号化と同様のアルゴリズムで暗号化し、署名を生成する
4. 2のsignパラメータと3の署名を比較する
5. 2つの値が一致した場合は正常なセッションデータとして認証処理を継続する

実際、署名(signature)のチェック部分は下記のとおりです。

ZABBIX_ROOT/include/classes/core/CEncryptedCookieSession.php より

76
77
78
79
80
81
82
83
84
85
86
87
88
        protected function checkSign(string $data): bool {
                $data = json_decode($data, true);

                if (!is_array($data) || !array_key_exists('sign', $data)) {
                        return false;
                }

                $session_sign = $data['sign'];
                unset($data['sign']);
                $sign = CEncryptHelper::sign(json_encode($data));

                return $session_sign && $sign && CEncryptHelper::checkSign($session_sign, $sign);
        }

コードを見てもらえばわかると思いますが、

  1. セッションデータ中に sign パラメータがあること
  2. Zabbix側で生成した署名が空文字でない
  3. 1, 2 のデータが一致していること

これら3つ条件をクリアした場合に限り、署名のチェックをパスします。

Zabbix側での署名の生成は、CEncryptHelper::sign(json_encode($data)); によって、signature を作成しているようです。 詳しく見ていきます。

署名生成処理

署名の生成には、PHP の openssl_encrypt関数 4を使用していました。 openssl_encrypt関数は、第1引数に指定した文字列を第2引数で指定したアルゴリズムで暗号化します。 第3引数には、暗号化に使用するパスフレーズを指定します。

ZABBIX_ROOT/include/classes/helpers/CEncryptHelper.php より

89
90
91
92
93
        public static function sign(string $data): string {
                $key = self::getKey();

                return openssl_encrypt($data, self::SIGN_ALGO, $key);
        }

第2引数、第3引数ともに同一ファイルに定義されているようです。 暗号化方式の self::SIGN_ALGO は、下記のように定義されていました。

27
28
29
30
        /**
          * Signature algorithm.
         */
        public const SIGN_ALGO = 'aes-256-ecb';

パスフレーズについては、少し長いので省略しますが、CSettingsHelper::getGlobal(CSettingsHelper::SESSION_KEY) という形で初期化されており、読み解いていくとデータベース中の config テーブル中に session_key というカラムに設定された値を取得していました。

ですので、暗号化に用いられているパスフレーズを確認する場合は、Zabbixのデータベースに接続し、以下のSQLクエリを実行します。

mysql> SELECT session_key FROM config \G
*************************** 1. row ***************************
session_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
1 row in set (0.00 sec)

これで、暗号化アルゴリズムが、AES-256-ECBであること、暗号化に必要なパスフレーズが手に入りました。

簡単なセッションデータを元にした署名を生成

Zabbix APIで取得したセッションID、事前調査で取得した暗号化方式とパスフレーズを用いて、 sessionid だけをパラメータに持つセッション情報の署名を生成するコードを以下に示します。 実際には、連想配列からJSON文字列への変換やBase64エンコードを施して完全なBase64エンコードされた セッション情報として整形してあげる必要があります。

<?php

$cipher = "aes-256-ecb";
$key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
$sessionid = "12345678901234567890123456789012";

$plaintext = '{"sessionid":"' . $sessionid . '"}';
$sign = openssl_encrypt($plaintext, $cipher, $key);

echo $sign;
?>

認証をパスする暗号化されたセッションデータを生成(2021/10/31 追記)

最後にZabbix側で生成される暗号化されたセッションデータと同様のデータを生成するコードを以下に示します。 実際には、ページを遷移するごとにsessionid以外のパラメータも付与されるのですが、 こちらのコードで生成したセッションデータであれば、ひとまず認証をパスし、グラフ画像の生成をおこなうことはできました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
function generate_zbx_session($data, $cipher, $key)
{
    $sign = openssl_encrypt(json_encode($data), $cipher, $key);
    $data["sign"] = $sign;
    return base64_encode(json_encode($data));
}

function get_session_id() {
    return "12345678901234567890123456789012";
}

$cipher = "aes-256-ecb";
$key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";

$session = array();
$session["sessionid"] = get_session_id();

$zbx_session = generate_zbx_session($session, $cipher, $key);
echo $zbx_session;
?>
  • 10行目の sessionid を返す個所はZabbixAPIで取得した sessionidを返すように修正してください。
  • 13, 14行目の暗号化方式と暗号化キーは、前述した方法でソースコードおよびデータベースより取得してください。

やっていることは単純で以下のとおりです。

  1. セッションデータを連想配列で初期化します。(生成したsignを付与して再度エンコードする必要があるため、連想配列にしました)
  2. 連想配列として与えられたセッションデータをjson_encode関数を用いて文字列にエンコードします。
  3. エンコードした文字列と暗号化方式、暗号化キーを元に署名を生成します。
  4. 署名をセッションデータに付与します。
  5. 署名を付与したセッションデータを再度 json_encode関数で文字列化し、結果を base64_encode関数で暗号化します。

この関数で生成された文字列をCookie中のzbx_session 5 の値にセットすれば、Zabbix Webインタフェースの認証をパスできるはずです。

まとめ

本記事の内容を簡単にまとめます。

  1. Zabbixは ver5.4からセッションデータが暗号化されるようになった。(5.0 LTSのソースにはEncryptionに関するコードがありません)
  2. セッションデータは、改ざん防止のため署名情報が付与されている。
  3. 署名情報の生成には、Zabbix側で設定された暗号化方式(アルゴリズム)と暗号化キー(パスフレーズ)が必要になる。
  4. 暗号化方式は、コードに定数定義、暗号化キーはデータベースにそれぞれ格納されている。
  5. セキュリティの面から、これらの情報を持ち出す実装は避け、Zabbix Webインタフェースの認証からセッションデータを取得すべき。
  6. 今回は検証のため、あえてセッションデータを生成するプログラムを実装した。
  7. Zabbix APIの認証とZabbix Webインタフェースの認証、双方を使用することになるため流用できるように改善してほしい。(願望)

落ち葉拾い

今回は、PHPでの実装を記載しましたが、本来のレポート出力プログラムはPythonで実装したものとなります。 そのため、本筋とは全く関係ないOpenSSLを使用した暗号化関数の言語間での実装の違いによる問題や、JSON出力の細かな違いによる問題も発生しました。 それらについては、別記事でまとめたいと思います。


  1. https://www.zabbix.com/documentation/current/manual/api ↩︎

  2. https://www.zabbix.com/documentation/4.2/manual/api/reference/user/login ↩︎

  3. https://www.zabbix.com/documentation/current/manual/web_interface/cookies ↩︎

  4. https://www.php.net/manual/ja/function.openssl-encrypt.php ↩︎

  5. ZABBIX_SESSION_NAME はソースコード中に定義された値でデフォルトが zbx_session となっています。 ↩︎