본문 바로가기
WEB HACKING/웹 해킹[실습]

웹 시큐어 코딩 : 파일 업로드 & 다운로드

by madevth 2022. 2. 24.
반응형

저번 포스팅에서는 XSS : Cross-Site Scripting에 대응하여 웹 시큐어 코딩을 해보았다.

XSS 방어하기 : [모의해킹_실습] - 웹 시큐어 코딩 : XSS(Cross-Site Scripting)

이번 포스팅에서는 파일 업로드와 파일 다운로드 취약점을 막아보자!

 

파일 업로드 및 다운로드 취약점은 php가 실행되지 않는 별도의 서버를 두고 관리하는 것이 최선이지만,

업로드 폴더를 웹 경로에서 분리하고 DB에서 File ID를 통해 경로를 관리하자.

 

① 파일 업로드 취약점

파일 업로드 취약점은 php가 실행되지 않는 별도의 서버를 두고 관리하는 것이 최선이지만, 차선책으로 파일이 업로드되는 폴더를 웹 경로에서 분리하고 DB에서 File ID를 통해 경로에 접근하거나 파일을 DB 자체에 저장하는 방법이 있다.

필자는 파일을 DB에 저장하는 방법을 공부해보았다.

 

1) DB에 파일 저장하기

우선 파일이 들어갈 테이블을 하나 만들어주었다.

mysql> create table `upload`(
    -> `username` varchar(20) NOT NULL,
    -> `file_name` varchar(20) NOT NULL,
    -> `file` longblob NOT NULL,
    -> `size` varchar(200) NOT NULL,
    -> `type` varchar(20) NOT NULL
    -> ) ENGINE=MyISAM DEFAULT CHARSET=utf8;

필자는 파일을 올린 사용자 정보, 파일 이름, 파일, 파일 사이즈, 파일 형식 정보가 들어간 테이블을 생성해주었다.

MyISAM은 MySQL ver5.5 이전에 사용되던 기존 스토리지 엔진인데, 게시판을 이용하는 사용자가 이미지를 읽어 들이는 경우가 많으니 사용하는 것 같다. 사실 굳이 엔진을 MyISAM으로 설정할 필요가 있을까? 싶기는 하다.

 

이제 DB에 파일을 저장하는 코드를 살펴보자.

[file_upload.php - fail]

<?php
    include 'conn.php';
    $conn = new mysqli($Server, $ID, $PW, $DBname);
    session_start();
    if($_FILES['upload_file'] != NULL){
        $tmp_name = $_FILES['upload_file']['tmp_name'];
        $size = getimagesize($_FILES['upload_file']['tmp_name']);
        $type = $size['mime'];
        $img = fopen($_FILES['upload_file']['tmp_name'], 'rb');
        $size = $size[3];
        $maxsize = 2000000;
        $name = $_FILES['upload_file']['name'];

        if($_FILES['upload_file']['size'] < $maxsize ){
            $sql = "INSERT INTO testblob (username, file_name, file, size, type) VALUES (?, ?, ?, ?, ?)";
            $pre_state = $conn->prepare($sql);
            $pre_state->bind_param("sssss", $type, $img, $size, $name);
            $pre_state->execute();
        }
        else{
            echo "<script>alert('파일 사이즈가 너무 큽니다.')</script>";
        }
    }
?>

처음엔 위와 같이 코드를 작성했는데, 나중에 이미지를 읽어오는데 계속 실패했다.

 

 

[file_upload.php - success]

<?php
    include 'conn.php';
    $conn = new mysqli($Server, $ID, $PW, $DBname);
    session_start();
    if($_FILES['upload_file'] != NULL){
        $username = $_SESSION['id'];
        $tmp_name = $_FILES['upload_file']['tmp_name'];
        $size = getimagesize($_FILES['upload_file']['tmp_name']);
        $type = $size['mime'];
        $img = file_get_contents($_FILES['upload_file']['tmp_name']);
        
        $file_size = $_FILES['upload_file']['size'];
        $maxsize = 2000000;
        $name = $_FILES['upload_file']['name'];

        if($file_size < $maxsize){
            $sql = "INSERT INTO upload (username, file_name, file, size, type) VALUES (?, ?, ?, ?, ?)";
            $pre_state = $conn->prepare($sql);
            $pre_state->bind_param("sssss", $username, $name, $img, $file_size, $type);
            $pre_state->execute();

            if($result = $pre_state->get_result()){
                echo "<script>success</script>";
                echo "<script>window.location.href='board.php';</script>";
            }else{
                echo "<script>fail</script>";
            }
        }
        else{
            echo "<script>alert('파일 사이즈가 너무 큽니다.')</script>";
        }
    }
?>

그래서 file_get_contents 함수를 사용하여 이미지를 저장했다. DB에 아래와 같이 엄청나게 길게 저장된다면 성공이다.

 

2) DB에서 파일 불러오기

업로드된 파일에 접근을 막기 위해 DB를 사용한 것이기 때문에 원래라면 파일을 확인하는 기능은 제거하는 게 맞겠지만..

페이지에 따라 view 기능을 제공하는 곳도 있으니 일단 공부할 겸 파일을 불러오는 기능도 새로 만들어보았다.

[file_view.php]

<?php
    include 'conn.php';
    $conn = new mysqli($Server, $ID, $PW, $DBname);
    session_start();
    if(isset($_GET['id']) && isset($_SESSION['id'])){
    	[id를 이용하여 username과 file_name 가져오는 코드];
        $sql_file = "SELECT * from upload where username = ? and file_name = ?";
        $pre_state_file = $conn->prepare($sql_file);
        $pre_state_file->bind_param("ss", $user, $file_name);
        $pre_state_file->execute();

        $result = $pre_state_file->get_result();
        if($row = $result->fetch_assoc()){
            $type = $row['type'];
            $image = $row['file'];
            echo '<img src="data:'.$type.';base64,'.base64_encode($image).'"/>';
        }else{
            echo "<script>alert('존재하지않는 파일입니다.');</script>";
            echo "<script>window.location.href='read.php?id=$id';</script>";
        }
    }
?>

원래 게시판(board) 테이블에 파일 이름을 넣어두고, 새로 파일만 저장하는(upload) 테이블을 만든 것이기 때문에, 글 id를 이용하여 username과 file_name 가져오는 코드가 있었지만 생략하였다.

파일을 그냥 가져오면 인코딩 문제로 깨져서 볼 수가 없어서 <img src="data: $type;base64,base64_encode($image)"/>를 사용하였다.

 

 

② 파일 다운로드 취약점

파일 다운로드 취약점 역시 폴더를 웹 경로에서 분리하고 DB에서 File ID를 통해 경로에 접근하도록 할 수 있다.

웹 경로에 파일이 있을 때, File ID를 통해 접근하는 방법과 파일이 DB에 저장되어 있을 때 저장하는 법 두 가지를 모두 알아보자.

 

1) File ID를 통한 접근

우선 게시판에서 글을 읽을 때(read.php) HTML을 사용해 바로 다운로드하는 대신, file_download.php 파일을 새로 만들어 id를 넘겨주었다.

<a href="file_download.php?id=<?=$id?>">Download</a>

 

[file_download.php]

<?php
    include 'conn.php';
    $conn = new mysqli($Server, $ID, $PW, $DBname);
    session_start();
    if(isset($_GET['id']) && isset($_SESSION['id'])){
        $sql = "SELECT * FROM board where id = ?";

        $pre_state = $conn->prepare($sql);
        $pre_state->bind_param("s", $id);

        $id = $_GET['id'];
        $pre_state->execute();
        $result = $pre_state->get_result();

        $row = $result->fetch_assoc();
        $username = $row['username'];
        $file_name = $row['file'];
        $path = "./files/$username";
        $file = "$path/$file_name";

        if (is_file($file)) {
            header("Content-type: application/octet-stream"); 
            header("Content-Length: ".filesize("$file"));
            header("Content-Disposition: attachment; filename=$file_name");
            header("Content-Transfer-Encoding: binary");
            header("Pragma: public"); 
            header("Expires: 0"); 
        
            $fp = fopen($file, "rb"); 
            fpassthru($fp);
            fclose($fp);
        }
        else {
            echo "해당 파일이 없습니다.";
        }
    }else{
        echo "<script>alert('잘못된 접근입니다.'); history.back();</script>";
    }
    mysqli_close($conn);
?>

Prepared Statement를 사용해서 DB와 연결하였고, 파일 이름을 가져와 파일을 다운로드 하는 방식을 사용했다.

header()는 raw HTTP 헤더를 전송하기 위해 사용되는 함수이다.

 

2) DB 파일 저장하기

<?php
    include 'conn.php';
    $conn = new mysqli($Server, $ID, $PW, $DBname);
    session_start();
    if(isset($_GET['id']) && isset($_SESSION['id'])){
        [id를 이용하여 username과 file_name 가져오는 코드];
        $sql_file = "SELECT * from upload where username = ? and file_name = ?";
        $pre_state_file = $conn->prepare($sql_file);
        $pre_state_file->bind_param("ss", $username, $file_name);
        $pre_state_file->execute();

        $result = $pre_state_file->get_result();
        if($row = $result->fetch_assoc()){
            $type = $row['type'];
            $size = $row['size'];
            $name = $row['file_name'];
            $image = $row['file'];
            header("Content-type: $type"); 
            header("Content-Length: $size");
            header("Content-Disposition: attachment; filename=$name");
            header("Content-Transfer-Encoding: binary");
            ob_clean();
            flush();

            echo $image;
        }
        else {
            echo "<script>alert('해당 파일이 없습니다.'); history.back();</script>";
        }
    }else{
        echo "<script>alert('잘못된 접근입니다.'); history.back();</script>";
    }
    mysqli_close($conn);
?>

파일 다운로드 역시  id를 이용하여 username과 file_name 가져오는 코드가 있었지만 생략하였다.

DB에서 불러온 파일 보는 기능과 위의 파일 다운로드 받는 기능을 적당히 섞어 놓은 느낌이지만, 이미지가 자꾸 깨져서 제대로 저장되게 만드는 방법을 찾느라 고생 좀 했다.

 

이번 포스팅에서는 파일 업로드 / 다운로드 관련 취약점에 대응하기 위해 DB에 직접 파일을 저장하고, 불러오고, 다운로드하는 방법까지 알아보았다. 개인적으로 웹 시큐어 코딩 중에 가장 오래 걸리고 어려운 작업이니, 많은 구글링을 바탕으로 이것저것 시도를 해보는 것을 추천드린다.

반응형

댓글