WAFのリクエスト解析
検査対象へ値が届いたか、どの値が一致したか、どこまで本文を検査できたかを確認します。
説明
WAFはHTTP要求から引数や本文を取り出し、設定したルールへ照合します。URL符号化を何回解くか、本文をどの形式として解析するかによって、ルールに渡る値が変わります。
Corazaの固定試料ルールで、文字列demo-blockを検査します。通常の引数、二重符号化、JSON、引数の欠落、本文の上限を順に変え、WAFのログとアプリ到達を比較します。
検査対象へ値が届いたか、どの値が一致したか、どこまで本文を検査できたかを確認します。
1検査対象とルールを読む
baselineはARGS全体にdemo-blockが含まれると、phase 2で403を返す試料です。CRS全体の検知性能を測る例ではありません。
from sp01 import *
reset()
print(rules("baseline")現在の試料設定を読む)SecRuleEngine Onと本文アクセスを有効にしています。id:1001は引数、id:1100の連鎖ルールは/vulnerableの検索値に含まれる引用符を検査します。
2通常引数が一致した位置を調べる
qへ固定マーカーをそのまま入れます。
print(request("baseline"実WAFへHTTP要求を送る,"/echo?q=demo-block"))
print(summary()直近監査ログの一致情報を抽出する)403でアプリ未到達です。監査ログのid1001とARGS:q=demo-blockが、一致したルールと値を示します。
3URL符号化した同じ値を比較する
dだけを%64にします。
print(request("baseline","/echo?q=%64emo-block一度符号化した値を送る"))
print(summary())同じ403とdemo-blockの一致値です。クエリー解析時のURLデコードで%64がdになってから照合されます。
4二重符号化と追加変換を比較する
%自体を%25にした入力を、追加変換のない設定とt:urlDecodeを加えた設定へ送ります。
print(request("baseline","/echo?q=%2564emo-block"一度だけ解釈する設定へ送る))
print(request("decode"追加URLデコード付き設定へ送る,"/echo?q=%2564emo-block"))
print(summary())baselineでは%64emo-blockのままアプリへ到達し、decodeは追加の変換で403になります。変換を増やすことが常に正しいわけではなく、アプリが実際に解釈する回数と揃えます。
5JSONを引数として検査する条件を確認する
JSON本文は専用processorを選んだ設定と比較します。
body = b'{"q":"demo-block"}'
print(request("baseline","/echo",body,"application/json"JSON形式の本文を送る))
print(request("json"JSON processorを明示した設定へ送る,"/echo",body,"application/json"))
print(summary())baselineは200、JSON processorを設定した側は403です。本文アクセスがOnというだけで、すべての形式がARGSへ同じ形で展開されるとは限りません。
6引数が存在しない場合を別に検査する
値への一致ルールだけでは、そもそもqがない要求は一致しません。requiredには&ARGS:qの個数検査を追加しています。
print(request("baseline","/echo")["status"])
print(request("required","/echo")存在を必須にした設定へ送る["status"])
print(request("required","/echo?q="空値と欠落を比較する)["status"])順に200、400、200です。欠落と空文字列は異なります。必須か、空を許すか、形式を制限するかを別々の条件として定めます。
7本文検査の上限とアプリ到達を照合する
limitはSecRequestBodyLimitを16、ActionをRejectに設定しています。Coraza 3.7.0の境界を15・16・17バイトで調べます。
reset()
for size in (15,16,17)設定値の直前・一致・超過を比較する:
print(size,request("limit","/echo",b"x"*size,"text/plain")["status"])
print(observed()後段の受信量を照合する)15は200、16と17は413です。この版の実装は設定値に達した時点で拒否します。アプリ記録も15バイトの1件だけです。説明上の「上限以内」という解釈に頼らず、採用する版の境界を実測します。
まとめ
符号化と本文processorによって検査値が変わります。
引数の欠落・空値・不正値を別の条件で扱います。
検査上限と超過時の動作を、アプリ到達まで照合します。
操作履歴と解説を振り返れます。
Python HTTP client / Coraza 3.7.0 / coraza-caddy 2.5.0 / Caddy 2.11.4
from sp01 import *
reset()
print(rules("baseline"))SecRuleEngine On
SecRequestBodyAccess On
SecAuditEngine On
SecAuditLog /tmp/audit.log
SecAuditLogFormat JSON
SecAuditLogParts ABFHZ
SecRule ARGS "@contains demo-block" "id:1001,phase:2,deny,status:403,log,t:none,msg:'fixed marker',logdata:'%{MATCHED_VAR_NAME}=%{MATCHED_VAR}'"
SecRule REQUEST_URI "@beginsWith /vulnerable" "id:1100,phase:2,deny,status:403,log,chain,msg:'quote in search'"
SecRule ARGS:q "@contains '" "t:none"print(request("baseline","/echo?q=demo-block"))
print(summary()){'status': 403, 'origin': False, 'body': ''}
{'uri': '/echo?q=demo-block', 'engine': 'On', 'interrupted': True, 'matches': [(1001, 'ARGS:q=demo-block')]}print(request("baseline","/echo?q=%64emo-block"))
print(summary()){'status': 403, 'origin': False, 'body': ''}
{'uri': '/echo?q=%64emo-block', 'engine': 'On', 'interrupted': True, 'matches': [(1001, 'ARGS:q=demo-block')]}print(request("baseline","/echo?q=%2564emo-block"))
print(request("decode","/echo?q=%2564emo-block"))
print(summary()){'status': 200, 'origin': True, 'body': {'args': {'q': ['%64emo-block']}, 'bytes': 0, 'path': '/echo'}}
{'status': 403, 'origin': False, 'body': ''}
{'uri': '/echo?q=%2564emo-block', 'engine': 'On', 'interrupted': True, 'matches': [(1001, 'ARGS:q=demo-block')]}body = b'{"q":"demo-block"}'
print(request("baseline","/echo",body,"application/json"))
print(request("json","/echo",body,"application/json"))
print(summary()){'status': 200, 'origin': True, 'body': {'args': {}, 'bytes': 18, 'path': '/echo'}}
{'status': 403, 'origin': False, 'body': ''}
{'uri': '/echo', 'engine': 'On', 'interrupted': True, 'matches': [(1001, 'ARGS:json.q=demo-block')]}print(request("baseline","/echo")["status"])
print(request("required","/echo")["status"])
print(request("required","/echo?q=")["status"])200 400 200
reset()
for size in (15,16,17):
print(size,request("limit","/echo",b"x"*size,"text/plain")["status"])
print(observed())15 200
16 413
17 413
[{'args': {}, 'bytes': 15, 'method': 'POST', 'path': '/echo'}]演習完了