CTF/CODEGATE2022

[Study] CODEGATE2022 babyfirst Writeup

Vardy 2022. 3. 1. 15:00

주어진 페이지에 접근해보자. 사용자명을 입력하면 메모를 남길 수 있는 서비스가 제공되고 있다.

 

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외 실제 소스코드로써 어떻게 정의되어있는지 확인해보았다.

https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.base/share/classes/java/net/URL.java#L575

 

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}

반응형