エラーメッセージから読み取るZabbix5.4調査録

Page content

こんにちは。

前回の記事 では、Zabbix 5.4でのセッションデータの取り扱いの変化と対応方法についてまとめました。 今回は、前回の記事にあったような知見をどういった流れで調査をおこなっていったかについてまとめてみたいと思います。

調査の流れ

今回の流れは、不具合対応などでもよくあるエラーの事象を元に原因を推測・切り分けていく作業でした。

  1. レポートシステムの実行結果を確認
  2. グラフ画像取得部分のプログラムコードの動作確認
  3. ブラウザおよび開発者ツールでの認証まわりの挙動確認
  4. Zabbixソースコードの調査

詳細

問題の起点

前回の記事でも触れていますが、 Zabbixのバージョンアップにともない、動作検証のためレポートを出力するプログラムを実行したことが起点となります。 ちなみに、ZabbixはPHPで書かれていますが、レポート出力プログラムは、数値解析処理のようなことも後に考えていたため、 数値解析ライブラリが充実している python で書いています(pandas, numpy, 夢は機械学習)。

python report.py -t 2021-08

というように実行すると、指定した年月のデータをZabbixから取得して、HTML形式のレポートを整形出力する代物です。

プログラムそのものは、途中でエラーで異常停止することなく正常終了しました。 出力されたHTMLレポートを確認したところ、Zabbix APIからは登録されているホスト情報や取得すべきグラフの識別情報については、 正常に取得できていました。 しかし、リソースグラフの取得においてグラフ画像をPNG形式でローカルストレージに保存する仕様でしたが、保存された画像ファイルはビューワーで閲覧できませんでした。 保存された画像ファイルを開こうとすると下記のように「サポートしていない形式」を示すエラーメッセージが表示されました。

ローカルに保存されていたグラフ画像

確認された事象

1. Zabbix APIは正常に動作している。(ホスト・グラフ識別情報・アラート情報は取得出来ている)
2. グラフ画像の取得に失敗している。(pngデータとして認識されていない。すべてのファイルサイズが数KBになっていて大きさが似ている。)

グラフ描画プログラム chart2.php のレスポンスを確認

エラーメッセージから正常にPNGイメージデータが取得できていないことがわかったので、グラフ画像を取得しているコードのデバッグをおこないました。 Zabbix Webインタフェースからグラフ画像を取得する場合は、下記のように指定したグラフIDのリソースグラフを描画する chart2.php を呼び出す必要があります。 chart2.php が受け取ることができるパラメータなどの細かい内容については割愛しますが、APIで取得したグラフのID情報を params に、cookie の値に同様にAPIで取得したセッションIDを付与してHTTPリクエストをおこなっていました。

画像データを返す chart2.php へのHTTPリクエスト処理(抜粋)

1
2
3
zbx_session = get_session_id()
r = requests.get("http://127.0.0.1/zabbix/chart2.php", params=get_graph_params, cookies={'zbx_session': zbx_session})
return r.content

Zabbix5.4にアップグレードする以前は、r.content つまりHTTPレスポンスのコンテンツをそのまま保存すれば、PNG画像として認識されていました。 これまでの調査でこのレスポンス内容に問題があると推測していたため、 ファイルに書き出す内容をターミナル上に出力したところ、下記のようなHTMLコードが返されていることが確認できました。

chart2.phpのレスポンス(抜粋)

1
<body lang="en"><div class="wrapper"><main><output class="msg-bad msg-global">You are not logged in<div class="msg-details">

エラーメッセージの内容から、ユーザ認証をおこなっていない状態でZabbix Webインタフェースにアクセスした際に表示される エラーページであることが判明しました。 つまり、従来の認証方式ではZabbix 5.4からはログインできなくなったということが分かりました。

確認された事象

3. ZabbixのWebインタフェースのアクセスにおいて、ユーザ認証処理が変更された可能性が高い

ブラウザの開発ツールからCookie情報をデバッグ

API経由でのユーザ認証とその後のデータ取得は問題なくできていました。 APIで使用していたセッションIDをCookieに設定することで、これまでは問題なくZabbixのWebインタフェースにアクセスできていました。

そこで、考えたのが認証に用いるセッション情報の受け渡し方法が変わったりしていないかということでした。 問題の切り分けをおこなうために、WebブラウザからZabbixのWebインタフェースにログインし、 これまで使用されていたCookie情報に変化がないかを確認することにしました。

ブラウザでのデバッグには、開発者ツールを使用しました。 Microsoft Edgeの場合は、F12 キーから開発者ツールを開きます。 今回は、Cookieの情報を確認したかったため、開発者ツールメニューから 【アプリケーション】⇒【ストレージ】⇒【Cookie】の順にデータを展開し、保存されているCookieを確認しました。

Microsoft Edge 開発者ツール

開発社ツールでのデバッグから、Zabbix5.4では、zbx_session というキーに対して暗号化されたと思われる文字列がセットされていることが判明しました。 どうやら、名前の zbx_session については変更はないものの、データそのものは大きく変更されているようでした。

確認された事象

4. Cookie 中の zbx_session キーに対してセットされるデータが変更されている

curl コマンドによるデバッグ

この時点では、保存されている中身はよくわかっていませんでした。 データの内容について考える前にまずは、この値をCookieとして渡せば画像データが取得できるかを確認することにしました。 この値を渡せばこれまでと同様の挙動になるのであれば、次はこのデータの生成方法を確認すればよいと考えたからです。

無暗にレポートシステムのソースコードを修正するのはまずいと思い、簡単にHTTPリクエストとレスポンスを確認できる curl コマンドを使用することにしました。 curl コマンドは -b オプションに cookie の値を指定できます。下記のように開発ツールで確認した zbx_session の値を引き渡したところ、なにやらPNGデータのようなものが取得できました。

1
2
3
# curl -b 'zbx_session=<開発ツールで確認した値>' http://127.0.0.1/zabbix/chart2.php
    PNG
文字化けなので省略

どうやら、cookieのzbx_session にこの謎の文字列を渡せば認証を抜け、グラフ画像が取得できるということがわかりました。 一度取得した文字列をどの程度の期間使いまわせるか確認しましたが、 当然、セッションの有効期限が過ぎるとこの謎の文字列は無効化されるようで永続的な使用することはできないようです。

確認された事象

5. ブラウザでログイン後の zbx_session の値をセットすれば、プログラムからでも認証処理をパスできる
6. しばらく時間を空けると zbx_session の値は使用できなくなる

zbx_session をキーワードに調査を開始

ようやく、認証方式そのものは zbx_session の値に適切なデータをセットすればよいということが分かりました。 そこで、cookieの名前として使用している zbx_session という情報から謎の文字列を解明していくことにしました。

とりあえず、zbx_session というキーワードで検索したところ、公式サイトのAPIドキュメントが検索候補に挙がってきました

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

ページにある ZABBIX_SESSION_NAME の説明に Zabbix frontend session data, stored as JSON encoded by base64 と書かれており、 どうやらセッションデータはJSON形式でありBase64エンコードされているらしいということがここで判明しました。

Base64デコードしてみる

そこで、前述の開発者ツールで取得した謎の文字列を Base64デコードすると下記のようなテキストが表示されました。

{"sessionid":"01234567890","serverCheckResult":false,"serverCheckTime":1234567890,"sign":"bz+kp(略)"}

sessionid というのは、そのまま従来のZabbixAPIの認証で取得できるセッションIDがセットされていると推測されます。 serverCheckResult, serverCheckTime は、それぞれサーバのチェックをおこなった時刻とその結果を示していると思われます。 (後に、Zabbix フロントエンドにアクセスした際に定期的にチェックされる、Zabbix Serverプロセスの稼働チェックであることがわかりました。)

sign については、この時点では不明でした。

確認された事象

7. zbx_session には、Base64エンコードされたJSONデータがセットされている

sessionidのみを含んだJSONデータを送信してみる

これまでは、zbx_session = sessionid というように、セッションIDだけをセットすればよかったのですが、 いろいろパラメータを引き回したかったのでしょう。 JSON形式で複数のパラメータを渡すようになっていました。

とりあえず、sessionidにZabbix APIで取得したセッションIDをセットした、JSON文字列を作成し、 その文字列をBase64エンコードしてCookieにセットすればいけるかもしれないと考えました。 認証に必要なデータがsessionidだけであれば、Zabbix APIで取得したセッションIDから機械的にJSON文字列を生成できるからです。

ここで、レポートシステムの画像取得処理を下記のように修正しました。

レポートシステム修正箇所(抜粋)

1
2
3
4
5
6
7
import base64
import json

session = { "sessionid": self.auth }
zbx_session = base64.b64encode(json.dumps(session))
r = requests.get("http://127.0.0.1/zabbix/chart2.php", params=get_graph_params, cookies={'zbx_session': zbx_session})
return r.content

python の json モジュールの dumps メソッドを使用することで dict 型のデータを文字列表現に変換します。 変換した文字列を base64 モジュールの b64encode メソッドでBase64エンコードすれば望んだ結果が得られると考えました。 しかし、結果は依然として「ログインしていない」というメッセージが表示されていました。

確認された事象

8. zbx_session の値に含まれるパラメータは sessionid だけでは不十分

パラメータ中の sign について調査

ここから先は、前回の記事で書いているように、 Zabbixのソースコードから sign パラメータの出所を探していきました。

公式サイトからZabbixのソースコードをダウンロードし、grepコマンドでまずは sign を含むファイルを検索しました。 しかし、signという文字列は非常に多くのファイルで使用されており、すぐに特定はできませんでした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# sign を含むファイルを検索
grep -r -l "sign" .
./app/partials/configuration.filter.items.php
./app/partials/js/scheduledreport.subscription.js.php
./app/controllers/CControllerAuthenticationUpdate.php
./app/controllers/CControllerWidgetNavTreeView.php
./app/controllers/CControllerPopupItemTestSend.php
./app/controllers/CControllerPopupTriggerExpr.php
./app/controllers/CControllerAuthenticationEdit.php
(中略)

# sign を含むファイルの個数を検索
grep -r -l "sign" . | wc -l
160

そこで、signを含んだJSONデータのセット先となる zbx_session というキーを元に調査しました。 こちらは、該当数が少なくどうやらGoogleで検索したときに出てきた、 ZBX_SESSION_NAME という定数の値として定義されていることがわかりました。

1
2
3
4
grep -r "zbx_session" .
./js/class.dashboard.js:                        zbx_session_name: window.ZBX_SESSION_NAME,
./js/class.dashboard.js:                if (clipboard.zbx_session_name !== window.ZBX_SESSION_NAME) {
./include/defines.inc.php:define('ZBX_SESSION_NAME', 'zbx_session'); // Session cookie name for Zabbix front-end.

同様に ZBX_SESSION_NAME を含むファイルを検索したところ、こちらも比較的該当ファイルが少なく、 ファイル名から察するに CCookieSession.php が怪しいと目星をつけました。

1
2
3
4
5
6
7
8
grep -r -l "ZBX_SESSION_NAME" .
./js/class.localstorage.js
./js/class.dashboard.js
./jsLoader.php
./include/classes/core/CCookieSession.php
./include/classes/html/CLink.php
./include/classes/html/CTabView.php
./include/defines.inc.php

CCookieSession.php ファイルの中を見ましたが、中に sign に関する定義はありませんでした。 そこで、ファイルが保存されているディレクトリ内に他に情報がないか探すことにしました。 ディレクトリに対して sign を含む個所を検索したところ、CEncryptedCookieSession.php というそのものズバリなファイルがあることが判明しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
grep -r "sign" ./include/classes/core
./include/classes/core/CEncryptedCookieSession.php:             if (array_key_exists('sign', $data)) {
./include/classes/core/CEncryptedCookieSession.php:                     unset($data['sign']);
./include/classes/core/CEncryptedCookieSession.php:             $data['sign'] = CEncryptHelper::sign(json_encode($data));
./include/classes/core/CEncryptedCookieSession.php:      * Prepare data and check sign.
./include/classes/core/CEncryptedCookieSession.php:             if (!is_array($data) || !array_key_exists('sign', $data)) {
./include/classes/core/CEncryptedCookieSession.php:             $session_sign = $data['sign'];
./include/classes/core/CEncryptedCookieSession.php:             unset($data['sign']);
./include/classes/core/CEncryptedCookieSession.php:             $sign = CEncryptHelper::sign(json_encode($data));
./include/classes/core/CEncryptedCookieSession.php:             return $session_sign && $sign && CEncryptHelper::checkSign($session_sign, $sign);
./include/classes/core/CAjaxResponse.php:        * Assigns data that is returned in 'data' part of ajax response.

あとは、検索結果に散見されるコードを手掛かりに checkSign 関数の存在を発見し、 そこから芋づる式にいろいろ判明しました。

  1. json_decodeによるデコード
  2. sign パラメータの削除
  3. sign パラメータを除いたデータを再度JSON形式にエンコードして sign データを生成
  4. helper として定義されていた CEncryptHelper モジュール上で openssl_encrypt による暗号化
  5. 暗号化アルゴリズムは定数で固定化されている
  6. openssl_encrypt 関数の仕様を確認し、 $key 変数がパスフレーズであること
  7. パスフレーズは、設定値から読み込んでいること、updateKey で更新していること
  8. 更新処理には、SQLクエリが書かれており、 config テーブルから session_key フィールドを更新していること
  9. session_key の値がパスフレーズであったこと

あとは、この結果を元にJSONデータのopenssl暗号化による sign の生成検証につながっていきました。

まとめ

今回は、これまで正常に動作していたプログラム中から動作しない箇所の切り分け、 デバッグツールによる原因調査、自分が実装していないソースコードから該当箇所の検索という流れで問題解決をおこなうことができました。

今回のような流れは、特別なことではなく発生したエラーメッセージをよく読んで、発生源を調査していくという基本的なものです。 難読化されていないソースコードであれば、自分でコードを読み進めることもできます。(超大規模なのは難しいかもしれませんが。。。)

自分が書いたプログラムではないから知らないで終わらせるのではなく、理解しようと努力することが大事だと思います。