
إثبات مفهوم وتحليل مفصل لـ CVE-2021-28378، وهي ثغرة XSS مخزنة في Gitea تتيح حقن التعليمات البرمجية التعسفي وتصعيد الامتيازات وتنفيذ التعليمات البرمجية عن بُعد عبر خطافات git.
تفاصيل حول هذا CVE هنا.
يستهدف هذا CVE غياب تهريب السلاسل (string escaping) في جانب العميل للمحتوى الذي يتم جلبه من الخادم. فهو يمكّن المهاجم من حقن كود تعسفي بسهولة، عن طريق إنشاء تعليق على issue أو pull request. تم تقديم هذا في commit 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5، في نهاية يناير 2020، ولكن بسبب squash لا يمكننا معرفة من فعل ذلك بالضبط للتحقيق في أخطاء مماثلة تم تقديمها.
أولاً، دعنا نفهم من أين تأتي هذه الثغرة. لهذا، دعنا نرى الـ commit الذي يصلح هذا 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>
`
يمكننا أن نرى أنه لا يوجد أي تهريب HTML على label.name وissue.repository.full_name وissue.title وbody، وهي سلاسل يمكن للمستخدم التلاعب بها، وبالتالي حقن كود تعسفي.
لاحظ أن issue.repository.full_name لا يمكن التلاعب به فعلياً لحقن كود لأنه يجب أن يتبع قواعد كثيرة جداً ([\w._-]+) لا يمكننا تجاوزها مع هذا CVE.
هذا بالضبط ما يصلحه الـ 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، سنضيف popup ضعيف.
الآن، نحتاج إلى إيجاد أين يتم إرسال ref-issue إلى المستخدم: دعنا نستخدم grep (ونحتفظ فقط بالنتائج المهمة، أي ليس ملفات الاختبار أو ما شابهها)!
رقم الـ commit التالي يقابل وسم 1.12.4، وهو الإصدار الافتراضي من ملف docker-compose.yml الرسمي، المتأثر بهذا CVE.
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، بل يتم إنشاؤه ديناميكياً.
النتيجة الأخيرة ليست مهمة لأنها مجرد قاعدة sanitizer policy. أما البقية فمهمة، وسنرى السبب.
دعنا نتعمق في 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 (وهو ما نفترض أنه صحيح لأنه مبني بواسطة دوال/طرق أعلى في مكدس الاستدعاء).
ثم نتحقق مما إذا كانت بيانات العقدة تطابق مساراً مثل http://mygitea.com/gituser/myrepo/issues/1. إذا كان هناك تطابق، نستبدله برابط.
إذا كان الرابط يتعلق بنفس المستخدم والمستودع، نستبدله بنمط #<issue_id>، وإلا بـ <user>/<repo>#<issue_id>.
في كلتا الحالتين، لدينا كلاس ref-issue يُضاف لتوليد الـ popup.
نتائج grep الأخرى تقوم بنفس النوع من العمل.
الآن لدينا السياق، والذي يدور حول استبدال رابط مثل http://mygitea.com/gituser/myrepo/issues/1 برابط أقصر، يمكننا استنتاج أين تتم هذه المهمة: في issues و PR، حيث يمكنك إضافة label و/أو نشر تعليق.
لهذا، لنفترض أن لديك مستودعاً بصلاحيات كتابة.
انتقل إلى مستودعك، وأنشئ label جديد باسم يحتوي على كود تعسفي (مثل <script>alert('label.name')</script>).
انتقل إلى issues مستودعك، وأنشئ issue جديد بكود تعسفي في العنوان والنص، وأضف إليه الـ label السابق.
أنشئ issue جديد في أي مستودع، وأضف في نصه رابطاً إلى الـ issue المحقون لديك مثل http://mygitea.com/gituser/myrepo/issues/1.
سيتم استبداله بالرابط الذي رأيناه سابقاً، وعندما يمرر شخص ما مؤشر الفأرة فوق الرابط، سيتم تنفيذ الكود التعسفي: سيتم عرض popup يضم label.name.
ثبت أن CVE-2021-28378 قادر على تمكين حقن كود تعسفي. دعنا نتخيل ما يمكن أن يفعله المهاجم.
في Gitea، لديك رمز CSRF يؤكد هويتك عند ملء النماذج و/أو استدعاء API، مما يمنع مشاكل أمنية مثل تضمين iframe من Gitea لسرقة وصولاتك.
رمز CSRF مخزّن في ملفات تعريف الارتباط الخاصة بك (_csrf)، وكذلك في معظم صفحات الويب (باستثناء بعضها مثل /api/v1/swagger) التي يزورها المستخدم ضمن بعض الحقول... وهكذا في صفحات issues/PR.
الحقل الأكثر إثارة للاهتمام الذي يحتوي عليه موجود داخل الجزء المشترك من ترويسة صفحات HTML، دائمًا بنفس XPath: /html/head/meta[9].
بهذه البيانات، يمكننا الآن إرسال إجراءات موثقة من أي مستخدم متأثر بحمولتنا، بمجرد الحصول على رمز CSRF في JS، باستخدام ما يلي: (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content.