mail コマンドで任意のコマンドが実行できる
Fail2ban の mail-whois と呼ばれる機能(Ban の通知を whois 結果付きでメール送るやつ)で CVE-2021-32749 が出ていた。
これは mail コマンドに whois の内容を標準入力で渡しているのだけれど、mailtuils の mail
コマンドには ~!
エスケープ修飾子があると、その引数をコマンドみなしてシェル経由で実行するらしい。
確かに man を読むと書いてある。
mail(1): send/receive Internet mail - Linux man page
~!command Execute the indicated shell command, then return to the message.
確かに実行できる。
root@8a57e227da26:/# cat /tmp/pwn.txt aaa ~! uname -a bbb ccc root@8a57e227da26:/# cat /tmp/pwn.txt | mail -s 'aaa' user@example.com Linux 8a57e227da26 5.10.25-linuxkit #1 SMP Tue Mar 23 09:27:39 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux mail: cannot send message: Process exited with a non-zero status
man を読むと他にも色々あるっぽい。
~|
でもコマンド実行につながるケースがあるらしいし、~r
や ~w
でファイルの書き込みができる。
mail
コマンド経由で外部入力値を操作するようなアプリケーションをもし実装する場合は ~
をエスケープするだけで十分なのかな?
修正コミットを見た感じだと -E 'set escape'
というオプションを使うことで対応している。
CVE-2021-32740
修正コミットはこれ。
PoC は次の通り。テンプレートで利用される変数名を外部から操作できる場合に発生する。
GHSA では High
になっているが、このようなケースは少ないだろうと思う。
irb(main):032:0> template = Addressable::Template.new("http://example.com/{000000000000000000000000000000!}") => #<Addressable::Template:0x1e0 PATTERN:http://example.com/{000000000000000000000000000000!}> irb(main):033:0> Timeout.timeout(10) { template.expand({}) } Traceback (most recent call last): 23: from /Users/pivot_root/.anyenv/envs/rbenv/versions/2.7.2/bin/irb:23:in `<main>' 22: from /Users/pivot_root/.anyenv/envs/rbenv/versions/2.7.2/bin/irb:23:in `load' 21: from /Users/pivot_root/.anyenv/envs/rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.3.3/exe/irb:11:in `<top (required)>' 4: from (irb):33:in `<main>' 3: from /Users/pivot_root/.anyenv/envs/rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/timeout-0.1.1/lib/timeout.rb:112:in `timeout' 2: from (irb):33:in `block in <main>' 1: from /Users/pivot_root/.anyenv/envs/rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/addressable-2.7.0/lib/addressable/template.rb:595:in `expand' /Users/pivot_root/.anyenv/envs/rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/addressable-2.7.0/lib/addressable/template.rb:595:in `gsub!': execution expired (Timeout::Error)
ローカル環境開発を acme.sh + docker compose で実現する
色々試しつつも、最終的に落ち着いた。次のような感じ。
localhost.sandbox.directory
の証明書を acme.sh で取得localhost.sandbox.directory
の A レコードに 127.0.0.1 をセットする- 証明書は nginx のコンテナに volume mount する
1. acme.sh で証明書を取得
GitHub - acmesh-official/acme.sh: A pure Unix shell script implementing ACME client protocol
❯ ~/.acme.sh/acme.sh --issue -k ec-256 -d 'localhost.sandbox.directory' --dns --yes-I-know-dns-manual-mode-enough-go-ahead-please # ↑で取得した TXT レコードを localhost.sandbox.directory に設定する ❯ ~/.acme.sh/acme.sh --renew --ecc -k ec-256 -d 'localhost.sandbox.directory' --dns --yes-I-know-dns-manual-mode-enough-go-ahead-please
2. docker-compose で証明書をマウント
❯ cp -a ~/.acme.sh/localhost.sandbox.directory.in_ecc/* nginx/certs/ ❯ cat docker-compose.yml version: '3' services: web: image: nginx:1.21.0 volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf - ./nginx/certs:/etc/nginx/certs ports: - "8443:443" depends_on: - php links: - php php: image: php:7-fpm volumes: - ./app:/app ❯ cat nginx/default.conf server { index index.php index.html; listen 443 ssl; server_name localhost.sandbox.directory; ssl_certificate /etc/nginx/certs/fullchain.cer; ssl_certificate_key /etc/nginx/certs/localhost.sandbox.directory.key; root /app; location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass php:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; } }
User-Agent Client Hints のメモ
従来の User-Agent の問題
- 現在の UA は多くの情報が入っており、それをデフォルトで送信することはプライバシーの観点からやめたい
- パースが複雑になるので、バグの原因となりやすい
User-Agent Client Hints
従来の UA と同じ情報にアクセスにできるようにしつつ、プライバシーの保護や、よりパースをしやすく整備した。 サーバーサイドでは特定のヘッダを、フロントエンドは新しいシンプルな API を使って取得できる。
Chrome 84 以降では chrome://flags/#enable-experimental-web-platform-features
を有効にすることで利用できる。
User-Agent Client Hints にデフォルトで含まれるもの
Sec-CH-UA
... ブラウザ名と major/significant バージョンSec-CH-UA-Mobile
... モバイルデバイスかどうか (bool)
仕様の変更は今現在進んでいるので、デフォルトセットに変更があるかも。
これまでの UA の取得方法
navigator.userAgent
navigator.platform
(非推奨)navigator.appVersion
(非推奨)User-Agent
HTTP ヘッダ
User-Agent Client Hints への移行
前提として Secure Context である必要があるので注意。
フロントエンド
navigator.userAgentData
を使う
if (navigator.userAgentData) { // use hints } else { // fall back to user-agent string parse }
navigator.userAgentData
はブラウザのブランド名とバージョンをプロパティとして持っている。
> navigator.userAgentData < NavigatorUAData {brands: Array(3), mobile: false} brands: Array(3) 0: {brand: " Not;A Brand", version: "99"} 1: {brand: "Google Chrome", version: "91"} 2: {brand: "Chromium", version: "91"} length: 3 __proto__: Array(0) mobile: false __proto__: NavigatorUAData
より詳細な情報がほしい場合は navigator.userAgentData.getHighEntroyValues
を使う。Promise で返ってくることに注意。
> navigator.userAgentData .getHighEntropyValues(["architecture", "bitness", "model", "platform", "platformVersion", "uaFullVersion"]) .then(ua => { console.log(ua) }); < Promise {<pending>} < VM770:3 {architecture: "x86", model: "", platform: "macOS", platformVersion: "10_15_7", uaFullVersion: "91.0.4472.77"}
- モバイルの判定には
navigrator.userAgentData.mobile
を使う
const isMobile = navigator.userAgentData.mobile;
サーバーサイド
User-Agent
の代わりに Sec-CH-*
ヘッダを見るようにする。Chrome 91.0.4472.77 では、次のような HTTP リクエストヘッダがついている。
sec-ch-ua: " Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91" sec-ch-ua-mobile: ?0
さらに情報がほしい場合は Accept-CH
で指定すると、後続のリクエストから取得できる。
Accept-CH: Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA-Platform, Sec-CH-UA
もし First リクエストから取得したい場合は Critical-CH
ヘッダを使うらしい(試していないので未確認)。Critical-CH
ヘッダを使うと、ブラウザはそのヘッダを付けてリクエストをやり直す。
Critical-CH
は Accept-CH
と同じ値を持たなければいけない。
Critical-CH: Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA-Platform, Sec-CH-UA
Cross Origin リクエストの場合
https://blog.site で https://cdn.site のリソースを読み込んでいる場合、https://blog.site は Sec-CH-UA-Platform
をリクエストして取得できるが、https://cdn.site は取得できない。そのため、Permissions-Policy で明示的に https://cdn.site に委任する必要がある。委任できるリストは https://wicg.github.io/client-hints-infrastructure/#policy-controlled-client-hints-features で確認できる。
iframe の場合
blog.site
に次のような iframe を埋め込むと、そのさきの https://widget.site
は UH-UA-Model
を取得できる。
<iframe src="https://widget.site" allow="ch-ua-model"></iframe>
References
Clear Site Data
Clear Site Data
SPA とかで Cookie とか LocalStorage とか、そういうものを消し忘れてバグる、みたいなのがあるので、ヘッダで消せるようにする提案。
<?php header('Clear-Site-Data: "cookies", "executionContexts'); ?>
これで Cookie が全部消える。cache
や storage
などが指定できる。Chrome M61 で既に使えるようになっている。
ログアウト時になにか消し忘れるのを防止できるし、永続化する XSS などへの対応もできる。
Credential Management Level 1
Credential Management
- Web サイトが UA に資格情報をリクエストし、UA がそれを使用できるようにする API
- スキームがダウングレードする場合はクレデンシャルを共有してはいけない(MUST NOT)
- 呼び出された Origin と Credential の Origin を比較して決定される
- admin.example.com に保存されているクレデンシャルを www.example.com から呼び出すことは可能
- XSS でパスワード抜かれることに繋がりそう
- CSP で保護することが推奨されている
- Secure Context のみで利用可能
- Timing Attack 防止のため、回数に制限もかける (SHOULD)
<html> <body> <form action="/login.php" method="post" id="login"> <input name="username" /> <input type="password" name="password" /> <button type="submit">Login</button> </form> <button onclick="autoLogin()">Auto Login</button> <script> function autoLogin() { // navigator.credentials.get({ 'password': true }).then(credential => { if (credential) { console.log(credential); } else { console.log("has not credentials"); }}) navigator.credentials .get({ 'password': true }) .then(credential => { if (!credential) { return; } if (credential.type == 'password') { document.querySelector('input[name=username]').value = credential.id; document.querySelector('input[name=password]').value = credential.password; document.getElementById('login').submit(); } }); } </script> </body> </html>
Trusted Types
Trusted Types (TT) とは
DOM Based XSS を緩和するための機能。次のような危険な Sink にデータを渡す前に処理する(Trusted Type 型に変換する)ことができる。
<script src>
及び<script>
のテキストコンテンツの設定- 文字列から HTML を生成するもの
innerHTML
outerHTML
inserAdjacementHTML
<iframe> srcodc
document.write
document.writeln
DOMParser.parseFromString
- プラグインの実行
<embed src>
<object data>
<object codebase>
- JavaScript コードの実行
eval
setTimeout
setInterval
new Function()
// ❌ TypeError element.innerHTML = location.href; // ✅ TrustedHTML Object のみ可能 element.innerHTML = trustedHTML;
Trusted Types の使い方
Example
<?php header("Content-Security-Policy: trusted-types example dompurify; require-trusted-types-for 'script'"); ?> <html> <head> <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.2.6/purify.min.js" integrity="sha512-rXAHWSMciPq2KsOxTvUeYNBb45apbcEXUVSIexVPOBnKfD/xo99uUe5M2OOsC49hGdUrkRLYsATkQQHMzUo/ew==" crossorigin="anonymous"></script> </head> <body> <script> const policy = trustedTypes.createPolicy('example', { createHTML: (untsutedValue) => { return DOMPurify.sanitize(untsutedValue, {RETURN_TRUSTED_TYPE: true}) } }); // http://localhost/#'%22%3E%3Csvg/onload=alert(1)%3E const rawHTML = decodeURIComponent(location.hash.substring(1)); // document.body.innerHTML = rawHTML; document.body.innerHTML = policy.createHTML(rawHTML); </script> </body> </html>
当然、 meta
タグでも可能。
<meta http-equiv=content-security-policy content="require-trusted-types-for 'script'; trusted-types example dompurify">
report-only で適用する
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri //my-csp-endpoint.example
Enforce
Content-Security-Policy: require-trusted-types-for 'script'; report-uri //my-csp-endpoint.example
違反レポートの収集
window.addEventListener('securitypolicyviolation', console.error.bind(console));
ポリシーの作り方
自前でポリシーを用意する。
// createHTML() 関数を介して、TrustedHTML オブジェクトを生成できる myEscapePolocy を定義 // trustedTypes.createPolicy() に渡される JavaScript 関数は文字列を返すが、 // createPolocy() は戻り値を TrustedHTML などの正しい型で wrap する if (window.trustedTypes && trustedTypes.createPolicy) { const escapeHTMLPolicy = trustedTypes.createPolicy('myEscapePolicy', { createHTML: string => string.replace(/\</g, '<') }); }
const escaped = escapeHTMLPolicy.createHTML('<img src=x onerror=alert(1)>'); console.log(escaped instanceof TrustedHTML); // true el.innerHTML = escaped; // '<img src=x onerror=alert(1)>'
DOMPurify は Trusted Types をサポートしているので、Sanitize 結果として TrustedHTML
オブジェクトを返すことができる。
if (window.trustedTypes && trustedTypes.createPolicy) { // Feature testing trustedTypes.createPolicy('default', { createHTML: (string, sink) => DOMPurify.sanitize(string, {RETURN_TRUSTED_TYPE: true}) }); }
この辺は Sanitize API が普及すると、それを使うようになるだろう。
default
ポリシー
default
というポリシー名を利用すると、Sink にデータが渡されると暗黙的に呼び出される。第一引数は文字列値、第二引数はシンク名となる。
TrustedType
trustedHTML
... HTML としてレンダリングしてよい信頼された文字列。TrustedTypePolicy
のcreateHTML
によって生成される。trustedScript
... Script として実行してよい信頼された文字列。TrustedTypePolicy
のcreateScript
によって生成される。trustedScriptURL
... 外部のスクリプトリソースとして信頼された文字列。TrustedTypePolicy
のcreateScriptURL
によって生成される。
ブラウザの対応状況
2020/05/08 現在
Chrome 83 で対応。他のブラウザでは polyfill で対応できる。
ライブラリでの対応
当然使っているライブラリで危険な Sink を使っている場合は対応が必要になる。
e.g. https://github.com/bvaughn/react-virtualized/pull/1614
Security
色々バイパスする方法はあるっぽい。
Trusted Types bypass challenge solutions | shhnjk.github.io
Blob URL
const blob = new Blob([untrustedString], {type: 'text/html'}); const url = URL.createObjectURL(blob); location = url;
Blob URL は TT で制限できない。その理由は次の通り。
type
引数は安全なもの (text/plain
など) にすることができるURL.createObjectURL
は File オブジェクトと MediaSource オブジェクトも受け入れる
Non-DOM API Based script
DOM API ではないスクリプトの読み込みは TT で制限できない。
import(`/${untrustedString}.js`)
Cross Document
TT は HTMLAnchorElement.href
や HTMLIFrameElement.src
などの sink に JavaScript URL を代入することをブロックしない。
CSP と同じように、TT が適用されたドキュメントで JavaScript URL への navigation をブロックするようになっている。
しかしながら、CSP でも既知の問題である Cross Document 間での相互作用の問題があり、一方のドキュメントに TT が適用されていない場合、もう一方の TT を Bypass できてしまう。これは https://wicg.github.io/origin-policy/ で対応しようとしている。
具体例は次の通り。robots.txt
に TT が適用されていない場合、JavaScript URL は robots.txt の iframe のドキュメントで実行されるので、XSS が生じる。
<!DOCTYPE html> <html lang="en"> <head> <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.2.6/purify.min.js" integrity="sha512-rXAHWSMciPq2KsOxTvUeYNBb45apbcEXUVSIexVPOBnKfD/xo99uUe5M2OOsC49hGdUrkRLYsATkQQHMzUo/ew==" crossorigin="anonymous"></script> <meta http-equiv=content-security-policy content="require-trusted-types-for 'script'; trusted-types default dompurify"> </head> <body> <iframe src="robots.txt" name="new"></iframe> <script> trustedTypes.createPolicy('default', { createHTML: (untsutedValue) => { return DOMPurify.sanitize(untsutedValue, {RETURN_TRUSTED_TYPE: true}) }, createScriptURL: (url) => { return "" } }); const url = "javascript:alert(origin)"; const a = document.createElement('a'); a.href = url; a.target = 'new'; a.textContent = 'go'; document.body.appendChild(a); </script> </body> </html>
HTMLAnchorElement properties (Polyfill only)
2020/05/08 現在だと Polyfill の場合は次のような形でバイパスが可能。
const policy = trustedTypes.createPolicy('default', { createScriptURL: (untsutedValue) => { return ''; } }); const rawHTML = decodeURIComponent(location.hash.substring(1)); const a = document.createElement('a'); a.href = policy.createScriptURL('http://example.com'); a.pathname = '\nalert(1)'; a.protocol = 'javascript:'; document.body.appendChild(a); a.click();
その他
よく tt
と略されるので issue とか追いかけるときは頭に入れておくといい。