[Study] CODEGATE2022 babyfirst Writeup
주어진 페이지에 접근해보자. 사용자명을 입력하면 메모를 남길 수 있는 서비스가 제공되고 있다.
Dokerfile을 보면 플래그는 /flag 에 저장되어있음을 알 수 있다.
### Dockerfile
FROM ubuntu:20.04
RUN apt-get -y update && apt-get -y install software-properties-common
RUN apt-get install -y openjdk-11-jdk
RUN apt-get -y install wget
RUN mkdir /usr/local/tomcat
RUN wget https://archive.apache.org/dist/tomcat/tomcat-8/v8.5.75/bin/apache-tomcat-8.5.75.tar.gz -O /tmp/tomcat.tar.gz
RUN cd /tmp && tar xvfz tomcat.tar.gz
RUN cp -Rv /tmp/apache-tomcat-8.5.75/* /usr/local/tomcat/
RUN rm -rf /tmp/* && rm -rf /usr/local/tomcat/webapps/
COPY src/ROOT/ /usr/local/tomcat/webapps/ROOT/
COPY flag /flag
COPY start.sh /start.sh
RUN chmod +x /start.sh
CMD ["/start.sh"]
핵심인 MemoServlet.class 파일을 디컴파일 해보자.
package controller;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.URL;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Base64;
import java.util.HashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class MemoServlet extends HttpServlet {
private Connection conn = null;
private void alert(HttpServletRequest req, HttpServletResponse res, String msg, String back) throws ServletException, IOException {
res.setContentType("text/html");
PrintWriter pw = res.getWriter();
pw.println("<script>");
pw.println("alert('" + msg + "')");
if (back != null && back.length() > 0) {
pw.println(";location.href='" + back + "';");
}
pw.println("</script>");
pw.close();
}
private boolean isLogin(HttpServletRequest req) {
if (req.getSession().getAttribute("name") == null) {
return false;
}
return true;
}
private String lookupPage(String uri) {
String[] array = uri.split("\\/");
if (array.length != 3) {
return "error";
}
return array[2].trim();
}
private void doLogin(HttpServletRequest req) throws ServletException, IOException {
String name = req.getParameter("name");
if (name == null || name.length() <= 0 || name.length() > 100) {
name = "noname";
}
req.getSession().setAttribute("name", name);
}
private void doWrite(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException, SQLException {
String name = (String) req.getSession().getAttribute("name");
String memo = req.getParameter("memo");
if (memo == null || memo.length() <= 0) {
memo = "no memo";
}
if (memo.length() > 2000) {
memo = "too long";
}
PreparedStatement pstmt = null;
try {
PreparedStatement pstmt2 = this.conn.prepareStatement("INSERT INTO memos (`name`, `memo`) VALUES (?,?)");
pstmt2.setString(1, name);
pstmt2.setString(2, memo);
if (pstmt2.executeUpdate() > 0) {
alert(req, res, "write", "/memo/list");
} else {
alert(req, res, "error", "/memo/list");
}
if (pstmt2 != null) {
pstmt2.close();
}
alert(req, res, "error", "/memo/list");
} catch (Exception e) {
if (0 != 0) {
pstmt.close();
}
} catch (Throwable th) {
if (0 != 0) {
pstmt.close();
}
throw th;
}
}
private HashMap<Integer, String> getList(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException, SQLException {
String name = (String) req.getSession().getAttribute("name");
PreparedStatement pstmt = null;
try {
PreparedStatement pstmt2 = this.conn.prepareStatement("SELECT * FROM memos WHERE `name`=? ORDER BY idx DESC");
pstmt2.setString(1, name);
HashMap<Integer, String> result = new HashMap<>();
ResultSet rs = pstmt2.executeQuery();
while (rs.next()) {
result.put(Integer.valueOf(rs.getInt(1)), rs.getString(3));
}
if (pstmt2 == null) {
return result;
}
pstmt2.close();
return result;
} catch (Exception e) {
System.out.println(e.getMessage());
if (0 != 0) {
pstmt.close();
}
return null;
} catch (Throwable th) {
if (0 != 0) {
pstmt.close();
}
throw th;
}
}
private static String lookupImg(String memo) {
Matcher matcher = Pattern.compile("(\\[[^\\]]+\\])").matcher(memo);
if (!matcher.find()) {
return "";
}
String img = matcher.group();
String tmp = img.substring(1, img.length() - 1).trim().toLowerCase();
Matcher matcher2 = Pattern.compile("^[a-z]+:").matcher(tmp);
if (!matcher2.find() || matcher2.group().startsWith("file")) {
return "";
}
String urlContent = "";
try {
BufferedReader in = new BufferedReader(new InputStreamReader(new URL(tmp).openStream()));
while (true) {
String inputLine = in.readLine();
if (inputLine != null) {
urlContent = urlContent + inputLine + "\n";
} else {
in.close();
try {
return memo.replace(img, "<img src='data:image/jpeg;charset=utf-8;base64," + new String(Base64.getEncoder().encode(urlContent.getBytes("utf-8"))) + "'><br/>");
} catch (Exception e) {
return "";
}
}
}
} catch (Exception e2) {
return "";
}
}
private String getMemo(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException, SQLException {
String name = (String) req.getSession().getAttribute("name");
try {
int idx = Integer.parseInt(req.getParameter("idx"));
PreparedStatement pstmt = null;
try {
PreparedStatement pstmt2 = this.conn.prepareStatement("SELECT * FROM memos WHERE name=? AND idx=?");
pstmt2.setString(1, name);
pstmt2.setInt(2, idx);
ResultSet rs = pstmt2.executeQuery();
if (rs.next()) {
String memo = rs.getString(3);
String tmp = lookupImg(memo); ##### ......................... (1)
if (!"".equals(tmp)) {
if (pstmt2 != null) {
pstmt2.close();
}
return tmp;
} else if (pstmt2 == null) {
return memo;
} else {
pstmt2.close();
return memo;
}
} else if (pstmt2 == null) {
return "";
} else {
pstmt2.close();
return "";
}
} catch (Exception e) {
alert(req, res, "error", "/memo/list");
if (0 != 0) {
pstmt.close();
}
alert(req, res, "error", "/memo/list");
return "";
} catch (Throwable th) {
if (0 != 0) {
pstmt.close();
}
throw th;
}
} catch (Exception e2) {
return "";
}
}
public void init(ServletConfig config) {
try {
String dbUser = config.getInitParameter("dbUser");
String dbPass = config.getInitParameter("dbPass");
Class.forName("com.mysql.cj.jdbc.Driver");
this.conn = DriverManager.getConnection("jdbc:mysql://mysql:3306/memo?serverTimezone=UTC", dbUser, dbPass);
} catch (Exception e) {
e.printStackTrace();
}
}
public void destroy() {
try {
this.conn.close();
} catch (SQLException throwables) {
throwables.printStackTrace();
}
}
public void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
if (!isLogin(req)) {
alert(req, res, "login first", "/");
return;
}
String page = lookupPage(req.getRequestURI());
if ("list".equals(page)) {
try {
req.setAttribute("list", getList(req, res));
} catch (SQLException throwables) {
throwables.printStackTrace();
}
} else if ("read".equals(page)) {
try {
req.setAttribute("memo", getMemo(req, res));
} catch (SQLException throwables2) {
System.out.println(throwables2.getMessage());
}
}
req.getRequestDispatcher("/WEB-INF/jsp/" + page + ".jsp").forward(req, res);
}
public void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
String page = lookupPage(req.getRequestURI());
if (page.equals("login") || isLogin(req)) {
char c = 65535;
switch (page.hashCode()) {
case 103149417:
if (page.equals("login")) {
c = 0;
break;
}
break;
case 113399775:
if (page.equals("write")) {
c = 1;
break;
}
break;
}
switch (c) {
case 0:
doLogin(req);
alert(req, res, "welcome", "/memo/list");
return;
case 1:
try {
doWrite(req, res);
return;
} catch (SQLException throwables) {
alert(req, res, "error", "/memo/list");
System.out.println(throwables.getMessage());
return;
}
default:
alert(req, res, "error", "/memo/list");
return;
}
} else {
alert(req, res, "login first", "/");
}
}
}
http://3.39.72.134/memo/write 에 접근하여 글을 작성 할 수 있고,
http://3.39.72.134/memo/read?idx=14446 와 같이 작성한 글을 읽을 수 있다.
그 외 소스를 분석하다가 처음에는 SQL 관련 로직에서 취약점이 있을까 살펴봤는데, prepareStatement 처리가 잘 되어있었다.
어쩌면 취약점이 존재할수있는 거의 유일한 로직은 (1)의 memo를 읽는 과정에서의 lookupImg 로직이었다. 해당 로직을 자세하게 분석해보자.
private static String lookupImg(String memo) {
Matcher matcher = Pattern.compile("(\\[[^\\]]+\\])").matcher(memo); ### ... (2)
if (!matcher.find()) {
return "";
}
String img = matcher.group();
String tmp = img.substring(1, img.length() - 1).trim().toLowerCase();
Matcher matcher2 = Pattern.compile("^[a-z]+:").matcher(tmp); ### .......... (3)
if (!matcher2.find() || matcher2.group().startsWith("file")) {
return "";
}
String urlContent = "";
try {
BufferedReader in = new BufferedReader(new InputStreamReader(new URL(tmp).openStream()));
##### ................................................................. (4)
while (true) {
String inputLine = in.readLine();
if (inputLine != null) {
urlContent = urlContent + inputLine + "\n";
} else {
in.close();
try {
return memo.replace(img, "<img src='data:image/jpeg;charset=utf-8;base64," + new String(Base64.getEncoder().encode(urlContent.getBytes("utf-8"))) + "'><br/>");
##### ..................................................... (5)
} catch (Exception e) {
return "";
}
}
}
} catch (Exception e2) {
return "";
}
}
(2)와 같이 memo의 컨텐츠 [ ] 안에 내용을 tmp 로 읽는다.
그 tmp의 내용을 (3)과 같이 URL 의 protocol 부분 처럼 알파벳:(ex http:) 형식이되, file protocol이 아니면,
(4)처럼 해당 URL을 openStream() 으로 접근하여 결과 값을 (5)와 같이 base64화 하여 이미지로 출력해준다.
로컬에서 테스트 환경을 구축하여 테스트해보니 실제로 (3)이하의 if문이 존재하지 않다면 로컬 파일을 읽을 수 있음을 확인했다.
하지만, file protocol을 활용 할 수 없기 때문에 처음에는 URL.openStream() 이 작동하는데 어떤 프로토콜들을 사용할 수 있는지 조사해보았다.
https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/URL.html
URL (Java SE 11 & JDK 11 )
Creates a URL object from the specified protocol, host, port number, and file. host can be expressed as a host name or a literal IP address. If IPv6 literal address is used, it should be enclosed in square brackets ('[' and ']'), as specified by RFC 2732;
docs.oracle.com
다음과 같은 프토콜을 사용 할 수 있다고 한다. 위에 언급된 프로토콜 외에 ftp 프로토콜도 사용할 수는 있음을 확인했다.
(jdk 7 에는 ftp프로토콜 언급되어있음 + 실제 테스트)
하지만 file 프로토콜을 제외하고는 로컬 파일에 접근할수 있는 방법을 찾지 못했다.
그래서 다른 방법을 찾기 위해 doc외 실제 소스코드로써 어떻게 정의되어있는지 확인해보았다.
GitHub - AdoptOpenJDK/openjdk-jdk11: Mirror of the jdk/jdk11 Mercurial forest at OpenJDK
Mirror of the jdk/jdk11 Mercurial forest at OpenJDK - GitHub - AdoptOpenJDK/openjdk-jdk11: Mirror of the jdk/jdk11 Mercurial forest at OpenJDK
github.com
...
public URL(URL context, String spec, URLStreamHandler handler)
throws MalformedURLException
{
String original = spec;
int i, limit, c;
int start = 0;
String newProtocol = null;
boolean aRef=false;
boolean isRelative = false;
// Check for permission to specify a handler
if (handler != null) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
checkSpecifyHandler(sm);
}
}
try {
limit = spec.length();
while ((limit > 0) && (spec.charAt(limit - 1) <= ' ')) {
limit--; //eliminate trailing whitespace
}
while ((start < limit) && (spec.charAt(start) <= ' ')) {
start++; // eliminate leading whitespace
}
if (spec.regionMatches(true, start, "url:", 0, 4)) { ##### ........ (6)
start += 4;
}
...
575 Line에 (6) 부분을 보면 url: 로 시작 할 시, 그 이후 값들부터 인식하도록 하는 로직이 있음을 확인했고,
이를 활용하여 아래와 같이 검증 로직을 우회 할 수 있었다.
[url:file:///flag]
FLAG = codegate2022{8953bf834fdde34ae51937975c78a895863de1e1}