
.jar 파일이라 java 디컴파일러로 확인해야 하는 문제이다.
사이트 자체의 기능은 로그인, 회원가입 밖에 존재하지 않으며 기본적으로 세션이 부여되고 있었다.
// package BOOT-INF.classes.com.hoshino.ai.controller
// ApiController.class
package BOOT-INF.classes.com.hoshino.ai.controller;
import com.hoshino.ai.controller.request.LoginRequest;
import com.hoshino.ai.controller.request.UserSearchRequest;
import com.hoshino.ai.domain.User;
import com.hoshino.ai.repository.UserRepository;
import jakarta.servlet.http.HttpSession;
import java.util.Optional;
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping({"/api"})
@Metadata(mv = {1, 7, 1}, k = 1, xi = 48, d1 = {"\000.\n\002\030\002\n\002\020\000\n\000\n\002\030\002\n\002\b\005\n\002\030\002\n\000\n\002\030\002\n\002\b\002\n\002\030\002\n\002\b\002\n\002\030\002\n\000\b\027\030\0002\0020\001B\r\022\006\020\002\032\0020\003\006\002\020\004J\032\020\007\032\0020\0012\006\020\b\032\0020\t2\b\b\001\020\n\032\0020\013H\027J\022\020\f\032\0020\0012\b\b\001\020\r\032\0020\016H\027J\032\020\017\032\0020\0012\006\020\b\032\0020\t2\b\b\001\020\020\032\0020\021H\027R\024\020\002\032\0020\003X\004\006\b\n\000\032\004\b\005\020\006\006\022"}, d2 = {"Lcom/hoshino/ai/controller/ApiController;", "", "userRepository", "Lcom/hoshino/ai/repository/UserRepository;", "(Lcom/hoshino/ai/repository/UserRepository;)V", "getUserRepository", "()Lcom/hoshino/ai/repository/UserRepository;", "login", "session", "Ljakarta/servlet/http/HttpSession;", "loginRequest", "Lcom/hoshino/ai/controller/request/LoginRequest;", "register", "user", "Lcom/hoshino/ai/domain/User;", "search", "userSearchRequest", "Lcom/hoshino/ai/controller/request/UserSearchRequest;", "ai"})
public class ApiController {
@NotNull
private final UserRepository userRepository;
public ApiController(@NotNull UserRepository userRepository) {
this.userRepository = userRepository;
}
@NotNull
public UserRepository getUserRepository() {
return this.userRepository;
}
@PostMapping({"/login"})
@NotNull
public Object login(@NotNull HttpSession session, @RequestBody @NotNull LoginRequest loginRequest) {
Intrinsics.checkNotNullParameter(session, "session");
Intrinsics.checkNotNullParameter(loginRequest, "loginRequest");
Intrinsics.checkNotNullExpressionValue(getUserRepository().findById(loginRequest.getId()), "userRepository.findById(loginRequest.id)");
Optional<User> user = getUserRepository().findById(loginRequest.getId());
if (user.isEmpty()) {
int $i$f$emptyArray = 0;
Intrinsics.checkNotNullExpressionValue(ResponseEntity.status(404).body(
new Integer[0]), "status(404).body(emptyArray<Int>())");
return ResponseEntity.status(404).body(new Integer[0]);
}
if (!Intrinsics.areEqual(((User)user.get()).getPassword(), loginRequest.getPassword())) {
int $i$f$emptyArray = 0;
Intrinsics.checkNotNullExpressionValue(ResponseEntity.status(403).body(new Integer[0]), "status(403).body(emptyArray<Int>())");
return ResponseEntity.status(403).body(new Integer[0]);
}
session.setAttribute("id", ((User)user.get()).getId());
session.setAttribute("isAdmin", Boolean.valueOf(((User)user.get()).getAdmin()));
Intrinsics.checkNotNullExpressionValue(ResponseEntity.ok(user), "ok(user)");
return ResponseEntity.ok(user);
}
@PostMapping({"/register"})
@NotNull
public Object register(@RequestBody @NotNull User user) {
Intrinsics.checkNotNullParameter(user, "user");
if (getUserRepository().findById(user.getId()).isEmpty()) {
user.setAdmin(false);
Intrinsics.checkNotNullExpressionValue(getUserRepository().save(user), "userRepository.save(user)");
User user1 = (User)getUserRepository().save(user);
Intrinsics.checkNotNullExpressionValue(ResponseEntity.ok(user1), "ok(user)");
return ResponseEntity.ok(user1);
}
int $i$f$emptyArray = 0;
Intrinsics.checkNotNullExpressionValue(ResponseEntity.status(400).body(new Integer[0]), "status(400).body(emptyArray<Int>())");
return ResponseEntity.status(400).body(new Integer[0]);
}
@PostMapping({"/admin-censored"})
@NotNull
public Object search(@NotNull HttpSession session, @RequestBody @NotNull UserSearchRequest userSearchRequest) {
Intrinsics.checkNotNullParameter(session, "session");
Intrinsics.checkNotNullParameter(userSearchRequest, "userSearchRequest");
if (session.getAttribute("isAdmin") != null && Intrinsics.areEqual(session.getAttribute("isAdmin"), Boolean.valueOf(true))) {
Intrinsics.checkNotNullExpressionValue(getUserRepository().findById(userSearchRequest.getId()), "userRepository.findById(userSearchRequest.id)");
Optional<User> user = getUserRepository().findById(userSearchRequest.getId());
((User)user.get()).setPassword("***");
Intrinsics.checkNotNullExpressionValue(ResponseEntity.ok(user), "ok(user)");
return ResponseEntity.ok(user);
}
int $i$f$emptyArray = 0;
Intrinsics.checkNotNullExpressionValue(ResponseEntity.status(403).body(new Integer[0]), "status(403).body(emptyArray<Int>())");
return ResponseEntity.status(403).body(new Integer[0]);
}
}
ApiController.class의 일부 코드를 확인하였을 때 로그인, 로그아웃 외에 숨겨져 있는 admin-censored 라우터가 존재한다는 걸 알 수 있었다.
관리자 여부는 세션의 isAdmin 속성이 존재하고 이 값이 true일 때, 그리고 전달된 유저 ID가 실제로 존재하는 ID일 때 응답에 성공하게 되며 해당 ID를 검색해 비밀번호를 ***로 가린 뒤 사용자에게 유저 정보를 보여준다.
외에도 일반 유저가 확인 가능한 Register, Login 페이지가 존재하며 회원가입 시 관리자 권한이 false로 전달되기에 계정 탈취는 불가능해 보였다.
// package BOOT-INF.classes.com.hoshino.ai.controller
// AiController.class
import jakarta.servlet.http.HttpSession;
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
@Metadata(mv = {1, 7, 1}, k = 1, xi = 48, d1 = {"\000 \n\002\030\002\n\002\020\000\n\002\b\002\n\002\020\016\n\000\n\002\030\002\n\000\n\002\030\002\n\002\b\003\b\027\030\0002\0020\001B\005\006\002\020\002J\"\020\003\032\0020\0042\006\020\005\032\0020\0062\006\020\007\032\0020\b2\b\b\001\020\t\032\0020\004H\027J\030\020\n\032\0020\0042\006\020\005\032\0020\0062\006\020\007\032\0020\bH\027\006\013"}, d2 = {"Lcom/hoshino/ai/controller/AiController;", "", "()V", "renderPage", "", "model", "Lorg/springframework/ui/Model;", "session", "Ljakarta/servlet/http/HttpSession;", "path", "root", "ai"})
public class AiController {
@GetMapping({"/"})
@NotNull
public String root(@NotNull Model model, @NotNull HttpSession session) {
Intrinsics.checkNotNullParameter(model, "model");
Intrinsics.checkNotNullParameter(session, "session");
model.addAttribute("user", session.getAttribute("id"));
return "index::home";
}
@GetMapping({"/{path}"})
@NotNull
public String renderPage(@NotNull Model model, @NotNull HttpSession session, @PathVariable @NotNull String path) {
Intrinsics.checkNotNullParameter(model, "model");
Intrinsics.checkNotNullParameter(session, "session");
Intrinsics.checkNotNullParameter(path, "path");
model.addAttribute("user", session.getAttribute("id"));
return "index::" + path;
}
}
import jakarta.servlet.http.HttpSession;
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
@Metadata(mv = {1, 7, 1}, k = 1, xi = 48, d1 = {"\000 \n\002\030\002\n\002\020\000\n\002\b\002\n\002\020\016\n\000\n\002\030\002\n\000\n\002\030\002\n\002\b\003\b\027\030\0002\0020\001B\005\006\002\020\002J\"\020\003\032\0020\0042\006\020\005\032\0020\0062\006\020\007\032\0020\b2\b\b\001\020\t\032\0020\004H\027J\030\020\n\032\0020\0042\006\020\005\032\0020\0062\006\020\007\032\0020\bH\027\006\013"}, d2 = {"Lcom/hoshino/ai/controller/AiController;", "", "()V", "renderPage", "", "model", "Lorg/springframework/ui/Model;", "session", "Ljakarta/servlet/http/HttpSession;", "path", "root", "ai"})
public class AiController {
@GetMapping({"/"})
@NotNull
public String root(@NotNull Model model, @NotNull HttpSession session) {
Intrinsics.checkNotNullParameter(model, "model");
Intrinsics.checkNotNullParameter(session, "session");
model.addAttribute("user", session.getAttribute("id"));
return "index::home";
}
@GetMapping({"/{path}"})
@NotNull
public String renderPage(@NotNull Model model, @NotNull HttpSession session, @PathVariable @NotNull String path) {
Intrinsics.checkNotNullParameter(model, "model");
Intrinsics.checkNotNullParameter(session, "session");
Intrinsics.checkNotNullParameter(path, "path");
model.addAttribute("user", session.getAttribute("id"));
return "index::" + path;
}
}
ApiController.class에선 특정 경로에 접근할 시 서버가 어떠한 동작을 할지가 명시되어 있었지만 AiController.class에선
유저가 보내는 값을 어떻게 처리하는지가 명시되어 있었다.
서버는 유저가 보내는 path를 interceptor의 SecurityFilterInterceptor.class의 필터링 과정을 거친 뒤, 필터링된 path 값을 받아 index::+path로 반환하게 된다.
이렇게 반환된 값은 index.html에서 받아 fragment로 전달되는데 이때 전달되는 fragment값을 검증하지 않아 취약점이 발생한다.
/${T(java.lang.Runtime).getRuntime().exec('whoami')}
만약 이러한 값이 일련의 필터링 없이 전달된다면 exec 함수가 동작해 whoami 명령어를 내부에서 실행하는 SSTI RCE 취약점이 존재하는 것이다.
따라서 아래 interceptor의 SecurityFilterInterceptor.class 파일의 필터링을 적절히 우회하여 RCE 취약점을 트리거하는 것으로 문제의 풀이방향을 확인할 수 있었다.
package BOOT-INF.classes.com.hoshino.ai.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.net.URLDecoder;
import java.util.Locale;
import kotlin.Metadata;
import kotlin.collections.ArraysKt;
import kotlin.jvm.internal.Intrinsics;
import kotlin.text.StringsKt;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@Metadata(mv = {1, 7, 1}, k = 1, xi = 48, d1 = {"\0002\n\002\030\002\n\002\030\002\n\002\b\002\n\002\020\021\n\002\020\016\n\002\b\f\n\002\020\013\n\002\b\003\n\002\030\002\n\000\n\002\030\002\n\000\n\002\020\000\n\000\b\027\030\0002\0020\001B\005\006\002\020\002J\020\020\021\032\0020\0222\006\020\023\032\0020\005H\026J \020\024\032\0020\0222\006\020\025\032\0020\0262\006\020\027\032\0020\0302\006\020\031\032\0020\032H\026R\034\020\003\032\b\022\004\022\0020\0050\004X\004\006\n\n\002\020\b\032\004\b\006\020\007R\034\020\t\032\b\022\004\022\0020\0050\004X\004\006\n\n\002\020\b\032\004\b\n\020\007R\034\020\013\032\b\022\004\022\0020\0050\004X\004\006\n\n\002\020\b\032\004\b\f\020\007R\034\020\r\032\b\022\004\022\0020\0050\004X\004\006\n\n\002\020\b\032\004\b\016\020\007R\034\020\017\032\b\022\004\022\0020\0050\004X\004\006\n\n\002\020\b\032\004\b\020\020\007\006\033"}, d2 = {"Lcom/hoshino/ai/interceptor/SecurityFilterInterceptor;", "Lorg/springframework/web/servlet/HandlerInterceptor;", "()V", "banWords", "", "", "getBanWords", "()[Ljava/lang/String;", "[Ljava/lang/String;", "banWordsPT", "getBanWordsPT", "banWordsSI", "getBanWordsSI", "banWordsTS", "getBanWordsTS", "banWordsXS", "getBanWordsXS", "isFiltered", "", "str", "preHandle", "request", "Ljakarta/servlet/http/HttpServletRequest;", "response", "Ljakarta/servlet/http/HttpServletResponse;", "handler", "", "ai"})
public class SecurityFilterInterceptor implements HandlerInterceptor {
@NotNull
private final String[] banWordsXS;
@NotNull
private final String[] banWordsSI;
@NotNull
private final String[] banWordsTS;
@NotNull
private final String[] banWordsPT;
@NotNull
private final String[] banWords;
public SecurityFilterInterceptor() {
String[] arrayOfString = new String[8];
arrayOfString[0] = "<";
arrayOfString[1] = ">";
arrayOfString[2] = "script";
arrayOfString[3] = "html";
arrayOfString[4] = "body";
arrayOfString[5] = "div";
arrayOfString[6] = "img";
arrayOfString[7] = "svg";
this.banWordsXS = arrayOfString;
arrayOfString = new String[3];
arrayOfString[0] = "'";
arrayOfString[1] = "--";
arrayOfString[2] = "#";
this.banWordsSI = arrayOfString;
arrayOfString = new String[5];
arrayOfString[0] = "${";
arrayOfString[1] = "#{";
arrayOfString[2] = "~{";
arrayOfString[3] = "%{";
arrayOfString[4] = "@{";
this.banWordsTS = arrayOfString;
arrayOfString = new String[1];
arrayOfString[0] = "..";
this.banWordsPT = arrayOfString;
this.banWords = (String[])ArraysKt.plus(ArraysKt.plus(ArraysKt.plus((Object[])getBanWordsXS(), (Object[])getBanWordsSI()), (Object[])getBanWordsTS()), (Object[])getBanWordsPT());
}
@NotNull
public String[] getBanWordsXS() {
return this.banWordsXS;
}
@NotNull
public String[] getBanWordsSI() {
return this.banWordsSI;
}
@NotNull
public String[] getBanWordsTS() {
return this.banWordsTS;
}
@NotNull
public String[] getBanWordsPT() {
return this.banWordsPT;
}
@NotNull
public String[] getBanWords() {
return this.banWords;
}
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
Intrinsics.checkNotNullParameter(request, "request");
Intrinsics.checkNotNullParameter(response, "response");
Intrinsics.checkNotNullParameter(handler, "handler");
String requestURI = request.getRequestURI();
String queryString = request.getQueryString();
HttpSession session = request.getSession();
if (requestURI != null && isFiltered(requestURI))
return false;
if (queryString != null && isFiltered(queryString))
return false;
Intrinsics.checkNotNullExpressionValue(URLDecoder.decode(requestURI, "UTF-8"), "decode(requestURI, \"UTF-8\")");
if (!((session != null) ? Intrinsics.areEqual(session.getAttribute("isAdmin"), Boolean.valueOf(true)) : 0) && StringsKt.contains$default(URLDecoder.decode(requestURI, "UTF-8"), "admin", false, 2, null))
return false;
return true;
}
public boolean isFiltered(@NotNull String str) {
Intrinsics.checkNotNullParameter(str, "str");
Intrinsics.checkNotNullExpressionValue(URLDecoder.decode(str, "UTF-8"), "decode(str, \"UTF-8\")");
Intrinsics.checkNotNullExpressionValue(URLDecoder.decode(str, "UTF-8").toLowerCase(Locale.ROOT), "this as java.lang.String).toLowerCase(Locale.ROOT)");
String decodedStr = URLDecoder.decode(str, "UTF-8").toLowerCase(Locale.ROOT);
String[] arrayOfString;
byte b;
int i;
for (arrayOfString = getBanWords(), b = 0, i = arrayOfString.length; b < i; ) {
String word = arrayOfString[b];
if (StringsKt.contains$default(decodedStr, word, false, 2, null))
return true;
b++;
}
return false;
}
}
'CTF' 카테고리의 다른 글
| [CodeGate 2025] Masquerade (0) | 2025.04.15 |
|---|---|
| [CodeGate 2024] Cha s Wall (0) | 2025.03.22 |
| [CodeGate 2023] CODEGATE Music Player (0) | 2025.03.21 |
| [CodeGate 2023] Calculator (0) | 2025.03.20 |
| [ISITDTU CTF] Another one Write-Up (0) | 2025.02.27 |