27 분 소요

File Upload 취약점 제거

  • 업로드되는 파일을 DB 테이블에 저장하면 된다.
  • File Upload 취약점을 없애기 위하여 업로드 파일 저장 위치를 db 테이블로 바꿔준다.
  • 즉, 웹 서버에 파일을 바로 저장하지 않고 DB 테이블에 바이너리 형태로 저장해준다.

SQL 테이블에 데이터( 파일 ) 저장

게시글 테이블

게시글 테이블 추가 컬럼

  • 업로드 파일 수 추가. 컬럼 디펄트 값 0
  • 파일 sid 컬럼을 추가. 컬럼 디펄트 값 “[]”

컬럼 추가 SQL

alter table notice_board add file_cnt NUMBER(20) default 0 not null;
alter table notice_board add file_sids NVARCHAR2(200) default '[]' not null;

file_cnt 추가 SQL

update notice_board set file_cnt=? where sid=?;

file_sids 리스트 추가 SQL

  • file_sids 에는 파일 sid 들의 리스트를 string 형태로 저장한다.
  • 예를 들면 [1, 2, 3] 이런식으로 저장한다.
update notice_board set file_sids=? where sid=?;

file sid list 및 file cnt 동시에 추가하기.

update notice_board set file_cnt=?,file_sids=? where sid=?;

파일 sid 리스트 불러오기

select file_sids from notice_board where sid = ?;

파일 테이블

컬럼

  • sid ( PRIMARY KEY )
  • 사용자 sid ( 외래키 )
  • 게시글 sid ( 외래키 )
  • 파일 이름
  • 파일 바이너리

FILE 테이블 만들기

  • 게시글이나 사용자 계정을 삭제하기 전에 notice_file 테이블에서 게시글, 사용자 계정과 관련된 데이터 부터 삭제해야 한다.
create table notice_file (
    sid NUMBER(20) PRIMARY KEY,
    user_sid NUMBER(20),
    notice_board_sid NUMBER(20),
    file_name NVARCHAR2(200),
    file_content blob,
    foreign key (user_sid) references users (sid),
    foreign key (notice_board_sid) references notice_board (sid)
);

notice_file 테이블 데이터 insert

insert into 
notice_file (sid, user_sid, notice_board_sid, file_name, file_content)
values (?,?,?,?,?)

notice_file 테이블 데이터 delete

delete from notice_file where sid=?
delete from notice_file where notice_board_sid=?
delete from notice_file where user_sid=?

파일 이름 찾기

select file_name from notice_file where sid=?;

파일 찾기

select file_content from notice_file where sid=?;

파일 새로운 게시글로 이전하기

update notice_file set notice_board_sid=? where sid=? ;

파일 업로드 사용자 sid 가져오기

select user_sid from notice_file where sid=? ;

업로드 과정

  • 게시글 성공적으로 업로드.

  • 게시글 삭제 과정

  • 사용자 계정 삭제 과정

Servlet Java 코드

MainPage.java

  • 파일 업로드 및 새로운 글 저장할 때. 파일 저장하는 부분을 수정하여 웹 서버에 파일을 저장하지 않고 DB 테이블에 파일을 저장하였다.
  • 파일을 바이너리 형태로 DB 테이블에 저장한다.
  
public class MainPage extends HttpServlet{
... 생략 ...
    private boolean uriSearch(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException{
    ... 생략 ...
    
        // 새로운 글 저장
        if(request.getMethod().equals("POST") && uri.equals(noticeBoardSave)){
            // ***** 파일 업로드 시작 *****
            var noticeRe = "no file";
            try{
                // 올바른 파일인지 확인하고 db에 저장한다.
                var parts = request.getParts();
                var fileSidList = new ArrayList<Long>();
                for(var part:parts){
                    if(!part.getHeader("Content-Disposition").contains("filename=")){
                        continue;
                    }
                    if(part.getSubmittedFileName().equals("")){
                        continue;
                    }
                    // 파일 이름에 금지된 문자가 사용되면 파일 업로드 안하기.
                    if(notCorrectFileName(part.getSubmittedFileName())){
                        continue;
                    }
  
                    if(fileSidList.size() > MAX_FILE_NUM){
                        noticeRe = "file max number";
                        break;
                    }
                    // 데이터 DB 테이블에 저장하기
                    var re = new NoticeFileDAO(getServletContext()).saveNoticeFile(user.getSid(), noticeSid, part);
                    part.delete(); // 임시파일 삭제
                    if(re != -1){
                        System.out.println("업로드 성공");
                        noticeRe = "file upload success";
                        fileSidList.add(re);
                    }else{
                        System.out.println("파일 업로드 실패");
                        noticeRe = "file upload fail";
                    }
                }
                // 게시글 file list 와 저장된 파일 갯수 저장하기.
                notice.updateFileListAndCnt(fileSidList, noticeSid);
            }catch(Exception e){
                e.printStackTrace();
                System.out.println("파일 업로드 실패");
                noticeRe = "file upload fail";
            }
            // ***** 파일 업로드 end *****
            ... 생략 ...
  • 게시글 테이블에 저장된 첨부파일 SID를 이용하여 파일 이름과 링크를 만들어 보여준다.
    Pasted image 20230611192027
    Pasted image 20230611192119
... 생략 ...
        // 글 자세히 보기
        if(request.getMethod().equals("GET") && uri.equals(noticeBoardDetail)){
        ... 생략 ...
                // *** 파일들 읽어 오기 ***
                // 파일 이름 찾기 및 href 링크 만들기
                var fileNameList = new ArrayList<Pair<String,String>>();
                var fileSidList = noticeDAO.getFileSidList(noticeSID);

                var noticeFileDAO = new NoticeFileDAO(getServletContext());
                for(var fileSid:fileSidList){
                    var fileName = noticeFileDAO.getFileName(fileSid);
                    fileNameList.add(new Pair<String,String>(fileName,fileSid.toString()));
                }
                request.setAttribute("fileNameList", fileNameList);
                request.setAttribute("noticeBoardFileDownload", noticeBoardFileDownload);
                // *** 파일들 읽어 오기 end ***
                ... 생략 ...
  • 게시글을 수정할 때 게시글이 가지고 있는 업로드 파일을 삭제하거나 다운 받을 수 있는 링크를 보여준다.
    Pasted image 20230611192607
... 생략 ...
        // mod 2 글 수정 하기
        if(request.getMethod().equals("GET") && uri.equals(noticeBoardModify)){
            try{
            ... 생략 ...
                // *** 파일들 읽어 오기 ***
                // 파일 이름 찾기 및 href 링크 만들기
                var fileNameList = new ArrayList<Pair<String,String>>();
                var fileSidList = noticeDAO.getFileSidList(noticeSID);

                var noticeFileDAO = new NoticeFileDAO(getServletContext());
                for(var fileSid:fileSidList){
                    var fileName = noticeFileDAO.getFileName(fileSid);
                    fileNameList.add(new Pair<String,String>(fileName,fileSid.toString()));
                }
                request.setAttribute("fileNameList", fileNameList);
                request.setAttribute("noticeBoardFileDownload", noticeBoardFileDownload);
                request.setAttribute("noticeBoardFileDelete", noticeBoardFileDelete);
                // *** 파일들 읽어 오기 end ***
               ... 생략 ...
  • 게시글 수정을 완료하고 저장을 할 때 동작 순서의 변화가 있었다.
  • 새롭게 업로드한 파일도 DB 파일 테이블에 저장해 준다.
... 생략 ...
        // 글 수정 저장하기
        if(request.getMethod().equals("POST") && uri.equals(noticeBoardModifySave)) {
        ... 생략 ...
            try {
                // 1. 전 저장위치
                var noticeSID = notice.getSid();
                // 2. 새로운 글 저장
                var genTime = System.currentTimeMillis();
                var newNoticeSid = noticeDAO.saveNotice(user, title, mainText, genTime);
                if(newNoticeSid==0){ // 실패
                    result = "<script>alert('fail');location.href='"+noticeBoard+"'</script>";
                    simplePage(response, result);
                    return true;
                }
                // 3. 이전 게시글 파일 새로운 글로 이전
                // notice_board_sid 새로운 글 sid로 바꾸기.
                var fileSidList = noticeDAO.getFileSidList(noticeSID);
                noticeDAO.updateFileListAndCnt(fileSidList, newNoticeSid);
                var fileDAO = new NoticeFileDAO(getServletContext());
                for(var fileSid:fileSidList){
                    fileDAO.changeNoticeSid(fileSid, newNoticeSid);
                }
                // 4. 이전 글 삭제
                if(!noticeDAO.deleteNotice(sid)){ // 삭제 실패
                    return false;
                }
                // 5. ***** 파일 업로드 시작 *****
                var noticeRe = "no new file";
                try{
                    // 올바른 파일인지 확인하고 db에 저장한다.
                    var parts = request.getParts();
                    for(var part:parts){
                        if(!part.getHeader("Content-Disposition").contains("filename=")){
                            continue;
                        }
                        if(part.getSubmittedFileName().equals("")){
                            continue;
                        }
                        // 파일 이름에 금지된 문자가 사용되면 파일 업로드 안하기.
                        if(notCorrectFileName(part.getSubmittedFileName())){
                            continue;
                        }
                        if(fileSidList.size() > MAX_FILE_NUM){
                            noticeRe = "file max number";
                            break;
                        }
                        // 데이터 DB 테이블에 저장하기
                        var re = new NoticeFileDAO(getServletContext()).saveNoticeFile(user.getSid(), newNoticeSid, part);
                        part.delete(); // 임시파일 삭제
                        if(re != -1){
                            System.out.println("업로드 성공");
                            noticeRe = "file upload success";
                            fileSidList.add(re);
                        }else{
                            System.out.println("파일 업로드 실패");
                            noticeRe = "new file upload fail";
                        }
                    }
                    // 게시글 file list 와 저장된 파일 갯수 저장하기.
                    noticeDAO.updateFileListAndCnt(fileSidList, newNoticeSid);
                }catch(Exception e){
                    e.printStackTrace();
                    System.out.println("파일 업로드 실패");
                    noticeRe = "new file upload fail";
                }
                // ***** 파일 업로드 end *****
                ... 생략 ...
  • 게시글에서 파일 다운로드 링크를 눌러 파일을 다운로드 받을 때 웹 서버의 폴더에서 파일을 가져오는 것이 아닌 DB 파일 테이블에서 파일을 가지고 온다.
  • 주의해야 할 점은 파일 다운로드가 다 될때 까지는 DB와의 연결이 끊겨서는 안된다.
... 생략 ...
        // 게시글 파일 다운로드
        if(uri.equals(noticeBoardFileDownload)){
            long pageSid;
            long fileSid;
            try {
                pageSid = Long.parseLong(request.getParameter("pageid"));
                fileSid = Long.parseLong(request.getParameter("downlink"));
            } catch (Exception e) {
                e.printStackTrace();
                return false;
            }
            var noticeDAO = new NoticeBoardDAO(getServletContext());
            var notice = noticeDAO.getNotice(pageSid);
            if(notice == null) {
                return false;
            }
            var fileDAO = new NoticeFileDAO(getServletContext());
            var is = fileDAO.getFile(fileSid);
            if(is == null){
                return false;
            }
            var fileSize = is.available();
            var fileName = fileDAO.getFileName(fileSid);
            // 사용자에게 다운로드 보내기
            var sMimeType = getServletContext().getMimeType(fileName);
            if(sMimeType == null || sMimeType.length() == 0){
                sMimeType = "application/octet-stream";
            }
            BufferedOutputStream outs = null;
            try {
                response.setContentType(sMimeType+"; charset=utf-8");
                if(fileSize > 0){
                    response.setContentLength(fileSize);
                }
                var userAgent = request.getHeader("User-Agent");
                System.out.println(userAgent);
                if(userAgent != null && userAgent.contains("MSIE 5.5")){ // MSIE 5.5 이하
                    return false;
                }else if(userAgent != null && userAgent.contains("MSIE")){ // MS IE
                    return false;
                }else{ // 모질라
                    response.setHeader("Content-Disposition", "attachment; filename="+ new String(fileName.getBytes("utf-8"), "latin1") + ";");
                }

                // 사용자에게 파일을 전송한다.
                outs = new BufferedOutputStream(response.getOutputStream());
                int read = -1;
                byte[] b = new byte[8192];
                while((read = is.read(b)) != -1){
                    outs.write(b, 0, read);
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    is.close();
                } catch (Exception e){
                    e.printStackTrace();
                }
                try {
                    outs.close();
                } catch (Exception e){
                    e.printStackTrace();
                }
                // 다운로드 완료 된후 DB 연결을 해제해야한다.
                fileDAO.Close();
            }
            System.out.println("사용자 파일 다운로드 "+ pageSid + "  "+ fileSize);
            return true;
        }
  • 게시글 수정 페이지에서 파일 삭제 버튼을 눌러 파일을 삭제할 때 사용자로 부터 받은 게시글 sid 와 파일 sid를 이용하여 파일 테이블에 저장된 파일 삭제해준다.
  • 파일을 삭제하기 전 파일을 삭제하는 권환을 가지고 있는지 확인한다.
        // 파일 삭제
        if(request.getMethod().equals("GET") && uri.equals(noticeBoardFileDelete)){
            var re = "fail";
            try{
                var noticeSid = Long.parseLong(request.getParameter("noticeID"));
                var fileSid = Long.parseLong(request.getParameter("filename"));
                var noticeDAO = new NoticeBoardDAO(getServletContext());
                var notice = noticeDAO.getNotice(noticeSid);
                // 게시글 존재하는지 확인하기
                ... 생략 ...
                // 글 작성자와 현재 유저가 일지하는지 확인
                ... 생략 ...
                
                // 업로드 파일 사용자 확인
                var fileDAO = new NoticeFileDAO(getServletContext());
                if(user.getSid() != fileDAO.getUploadUser(fileSid)){
                    System.out.println("글 작성자와 파일 업로드 사용자 명과 일치 안함");
                    simplePage(response, "{\"result\":\"fail\"}");
                    return true;
                }
                // 파일 삭제
                if(!fileDAO.deleteFile(fileSid)){
                    simplePage(response, "{\"result\":\"fail\"}");
                    return true;
                }
                // 파일 삭제 end
                re = "success";

                var fileSidList = noticeDAO.getFileSidList(noticeSid);
                fileSidList.remove(fileSid);
                noticeDAO.updateFileListAndCnt(fileSidList, noticeSid);
                System.out.println("삭제 완료!");
            }catch(Exception e){
                e.printStackTrace();
                re = "fail";
            }
            

NoticeBoardDAO.java

  • 전에는 게시글을 삭제할 때 게시글과 함께 게시글 업로드 폴더를 삭제 하였지만 현재는 게시글과 관련이 있는 DB 파일 테이블의 데이터를 삭제하면 된다.
  • 게시글을 삭제하기 전에 업로드 파일 부터 삭제하여야 한다. 그렇지 않으면 무결성 오류가 발생한다.
    Pasted image 20230611201006
public class NoticeBoardDAO {
    // 삭제
    public boolean deleteNotice(long sid){
        // 좋아요 테이블에서 게시글과 관련된 좋아요 기록 모두 삭제
        new NoticeLikeDAO(context).deleteNoticeLike(sid);
        // 업로드 파일 부터 삭제한다.
        new NoticeFileDAO(context).deleteNoticeFile(sid);
        ... 생략 ...
  • 게시글과 관련된 파일 sid 리스트를 String 타입으로 게시글에 저장해 주었다. 그리고 업로드 파일이 몇 개 인지도 저장해 주었다.
    // file list 저장 및 저장된 파일 수 저장
    public boolean updateFileListAndCnt(ArrayList<Long> fileSidList, long noticeSid){
        var re = false;
        var query = new StringBuilder();
        query.append("update ");
        query.append(tableName);
        query.append(" set file_cnt=?,file_sids=? where sid=?");
        try {
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1,fileSidList.size());
            pstmt.setString(2, fileSidList.toString());
            pstmt.setLong(3, noticeSid);
            pstmt.executeUpdate();
            re = true;
        } catch (Exception e) {
            e.printStackTrace();
            re = false;
        } finally {
            Close();
        }
        return re;
    }
  • 게시글에 저장된 파일 sid 리스트 String을 ArrayList로 변환해준다.
    // get file sid list
    public ArrayList<Long> getFileSidList(long noticeSid){
        ArrayList<Long> fileSidList = null;
        var query = new StringBuilder();
        query.append("select file_sids from ");
        query.append(tableName);
        query.append(" where sid=?");
        try {
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, noticeSid);
            var re = pstmt.executeQuery();

            String listStr = "[]";
            if(re.next()){
                listStr = re.getString(1);
            }
            fileSidList = strToArrayList(listStr);
        } catch (Exception e) {
            e.printStackTrace();
            fileSidList = new ArrayList<Long>();
        } finally {
            Close();
        }
        return fileSidList;
    }
  • String 타입을 ArrayList로 변환해준다.
    private ArrayList<Long> strToArrayList(String listStr){
        var arr = new ArrayList<Long>();
        if(listStr.length() > 2){
            arr.addAll(Arrays.stream(listStr.substring(1,listStr.length()-1).split(", ")).map(Long::parseLong).collect(Collectors.toList()));
        }
        return arr;
    }

NoticeFileDAO.java

  • 실제 DB 와 상호작용 할 수 있는 함수이다.
  • DB 테이블의 파일을 삭제, 저장을 할 수 있다.
  • 파일 이름이나 파일이 속한 게시글 sid, 사용자 sid 를 알 수 있다.
  • 테이블에 저장된 바이너리 형태의 파일을 불러올 수 있다.
public class NoticeFileDAO {
    private String tableName = "notice_file";
    private Connection conn;
    private DBConnection dbConn;
    public NoticeFileDAO(ServletContext context){
        dbConn = new DBConnection(context, "noticeBoard");
    }
    public long saveNoticeFile(long userSid, long noticeBoardSid, Part part){
        long re = -1;
        var sid = genSid();
        var query = new StringBuilder();
        query.append("insert into ");
        query.append(tableName);
        query.append(" (sid, user_sid, notice_board_sid, file_name, file_content) values(?,?,?,?,?)");
        try {
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, sid);
            pstmt.setLong(2, userSid);
            pstmt.setLong(3, noticeBoardSid);
            pstmt.setString(4, part.getSubmittedFileName());
            pstmt.setBinaryStream(5, part.getInputStream(), part.getSize());
            pstmt.executeUpdate();
            re = sid;
        } catch (Exception e) {
            e.printStackTrace();
            re = -1;
        } finally {
            Close();
        }
        return re;
    }
    // 게시글 에 속한 파일 삭제하기.
    public void deleteNoticeFile(long noticeSid){
        var query = new StringBuilder();
        query.append("delete from ");
        query.append(tableName);
        query.append(" where notice_board_sid=?");
        try {
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, noticeSid);
            pstmt.executeUpdate();
        } catch (Exception e) {
            e.printStackTrace();
        }finally{
            Close();
        }
    }
    public String getFileName(long sid){
        String fileName = "none";
        var query = new StringBuilder();
        query.append("select file_name from ");
        query.append(tableName);
        query.append(" where sid=?");
        try{
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, sid);
            var re = pstmt.executeQuery();
            if(re.next()){
                fileName = re.getString(1);
            }
        }catch(Exception e){
            e.printStackTrace();
            fileName="none";
        }finally {
            Close();
        }
        return fileName;
    }

    // 파일 다운로드 
    public InputStream getFile(long fileSid){
        InputStream re = null;
        var query = new StringBuilder();
        query.append("select file_content from ");
        query.append(tableName);
        query.append(" where sid=?");
        try {
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, fileSid);
            var result = pstmt.executeQuery();
            if(result.next()){
                var blob = result.getBlob(1);
                re = blob.getBinaryStream();
            }
        } catch (Exception e) {
            e.printStackTrace();
            re = null;
        }
        // 다운로드가 완료 되기 전까지 DB연결이 해제되어야 한다.
        // finally {
        //     Close();
        // }
        return re;
    }
    // 파일이 속하는 게시글 번호 바꾸기
    public boolean changeNoticeSid(long fileSid, long newNoticeSid){
        var re = false;
        var query = new StringBuilder();
        query.append("update ");
        query.append(tableName);
        query.append(" set notice_board_sid=? where sid=?");
        try{
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, newNoticeSid);
            pstmt.setLong(2, fileSid);
            pstmt.executeUpdate();
            re = true;
        }catch(Exception e){
            e.printStackTrace();
            re = false;
        }finally {
            Close();
        }
        return re;
    }
    // 파일 sid 로 삭제
    public boolean deleteFile(long fileSid){
        var re = false;
        var query = new StringBuilder();
        query.append("delete from ");
        query.append(tableName);
        query.append(" where sid=?");
        try {
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, fileSid);
            pstmt.executeUpdate();
            re = true;
        } catch (Exception e) {
            e.printStackTrace();
            re = false;
        }finally{
            Close();
        }
        return re;
    }

    // 파일 업로드 사용자 sid
    public long getUploadUser(long fileSid){
        long re = -1;
        var query = new StringBuilder();
        query.append("select user_sid from ");
        query.append(tableName);
        query.append(" where sid=?");
        try {
            Connection();
            var pstmt = conn.prepareStatement(query.toString());
            pstmt.setLong(1, fileSid);
            var result = pstmt.executeQuery();
            if(result.next()){
                re = result.getLong(1);
            }
        } catch (Exception e) {
            e.printStackTrace();
            re = -1;
        } finally {
            Close();
        }
        return re;
    }
    private long genSid(){
        long sid = -2;
        Connection();
        try {
            var query = new StringBuilder();
            query.append("select coalesce(max(sid),-1) from ");
            query.append(tableName);
            var pstmt = conn.prepareStatement(query.toString());
            var result = pstmt.executeQuery();
            if(result.next()){
                sid = result.getLong(1);
            }
        }catch(Exception e){
            System.out.println(e.getMessage());
        }finally{
            Close();
        }
        return sid+1;
    }

    private void Connection(){
        conn = dbConn.connectDB();
    }
    public void Close(){
        try{
            conn.close();
        }catch(Exception e){
            System.out.println(e.getMessage());
        }
    }
}

발생한 오류

ORA-00904: “NOTICE_FILE”: invalid identifier

  • notice_file 테이블에 파일 저장 컬럼의 이름이 file_content ( BLOB ) 인데 notice_file 로 컬럼 이름을 잘못적어 발생한 오류이다. 컬럼 이름을 바르게 바꿔주면 해당 오류는 사라진다.
  • notice_file 이라는 컬럼이 존재하지 않는다는 뜻이다.

java.lang.ClassCastException

  • class javax.sql.rowset.serial.SerialBlob cannot be cast to class oracle.jdbc.internal.OracleBlob
  • 이 에러는 어떻게 해결해야 하는지 모르겠다. 그냥 SerialBlob 을 사용하지않고 다른 방법을 이용하였다.

java.io.IOException: ORA-17008: Closed connection

  • java.sql.SQLRecoverableException: ORA-17008: Closed connection
  • DB 와 연결이 끊어져서 발생한 문제이다.
  • 작업을 마칠 때 까지 DB 연결을 끊어서는 안된다.

참고 사이트

댓글남기기