DVWA의 다섯 번째 실습 대상인 File Upload다. 선택한 파일을 업로드하는 기능을 가진 폼에 필터링이 제대로 구현되어 있지 않아 악성 파일들이 업로드될 수 있으며 이를 이용해 서버 측의 php 엔진에서 phpinfo() 등 php 코드를 실행하는 것이 목적이다.
File Upload?
현대 웹 애플리케이션에서 사진이나 비디오, 바이너리 등 다양한 파일을 서버에 업로드하는 기능은 거의 필수적으로 구현되어 있다. 하지만 이렇게 업로드되는 파일들을 필터링하지 않거나 사용자가 직접 접근하여 실행시킬 수 있다면 웹 애플리케이션에 심각한 영향을 끼칠 수 있다. 예를 들어 웹쉘 파일이 서버로 업로드될 경우 이 파일이 실행되기만 하면 RCE를 통해 서버 측에서 실행되어 중요 정보를 읽거나 서버를 장악할 수 있다. 업로드된 파일을 찾아서 실행하는 것과 별개로 일단 업로드된다면 File Upload 취약점이 발생한 것인데 파일이 저장된 곳이나 종류에 따라 서버 장악, 데이터베이스 과부하, 클라이언트 측 공격이나 디페이스 공격 등 다양한 결과를 낳을 수 있다.
File Upload 취약점이 존재한다면 크게 두 종류의 문제가 발생할 수 있다. 첫번째는 파일의 이름, 경로 등 메타데이터가 조작된 것으로 웹 애플리케이션이 파일을 다른 곳에 저장하거나 기존에 존재하는 파일을 덮어쓸 수도 있다. 그렇기 때문에 업로드된 파일을 서버에 저장하기 전에 파일이 메타데이터를 검증하는 로직이 구현돼야 한다. 두 번째 문제는 업로드된 파일이 너무 크거나 파일 내용이 올바르지 못한 것으로 해당 파일이 어디에 사용되는지에 따라 다양한 문제가 발생할 수 있는데 예를 들어 단순히 파일이 엄청 크다면 파일 저장 공간 서비스 거부 공격이 발생할 수 있고 웹쉘같은 실행 가능한 악성 스크립트 파일이 업로드된다면 취약한 라이브러리를 공격하거나 서버 장악에 활용될 수 있다. 따라서 업로드된 파일의 크기를 검사하거나 악성 스크립트가 포함된 파일이 아닌지 검사하는 등 여러 방어 기법을 적용해야 한다.
파일의 확장자를 검사하여 지정된 확장자가 아니면 업로드를 차단하는 일종의 Allow List나 Deny List를 사용하는 것도 좋은 방어 수단이지만 블랙리스트(Deny List) 방식을 사용할 경우 수많은 확장자를 일일히 기재해야 되는 데다가 잘 알려지지 않은 확장자(php5, phtml 등)를 누락할 수도 있기 때문에 권장되지 않는다. 확장자를 두 개 붙여서 'hack.php.jpg' 처럼 업로드하는 경우 웹 서버의 설정에 따라 php 코드가 실행될 수도 있다.
기본적인 수칙으로는 업로드된 파일의 크기, mime-type 등 기본적인 타입 검사를 수행한 후 인증된 사용자가 웹 애플리케이션에서 허용한 파일 형식만 업로드할 수 있도록 하는 것이다. 예를 들어 PHP에서는 파일이 실제로 웹 서버에 업로드되기 전 임시 파일로 존재하는데 이때 $_FILE['INPUT_NAME']['type'] 값에서 업로드된 파일의 mime-type을 확인할 수 있다. 여기서 'INPUT_NAME'은 파일을 업로드하는 input 태그의 name 속성 값이다.
PHP에서 업로드된 임시 파일을 실제로 디렉터리로 옮기는 move_uploaded_file() 함수는 업로드된 파일이 저장될 곳에 같은 파일이 존재해도 덮어 씌우기 때문에 중요 파일이 공격자의 파일로 덮어씌워질 수 있다. 아파치 서버에서는 '.htaccess' 파일에서 지정된 확장자만 받도록 설정하고 이 파일 자체가 업로드된 파일에 의해 덮어씌워지는 것을 방지하기 위해 업로드된 파일이 저장되는 곳 상위로 옮겨두는 방법 등이 있다.
업로드된 파일은 사용자가 LFI 등을 통해 접근할 수 없도록 웹 서버 외부 경로에 저장하거나 파일이 저장된 디렉터리의 실행 권한을 제거해서 웹쉘같은 악성 스크립트가 실행될 수 없도록 할 수 있다. 추가적으로 업로드된 파일 이름을 해시값이나 랜덤 값으로 변경해서 공격자가 어떤 파일을 업로드했는지 추측할 수 없도록 할 수 있다. 이 경우 파일의 원래 이름과 변경된 이름의 쌍을 데이터베이스에 저장해서 기억할 필요가 있다.
여러 취약한 방어 기법 및 적절한 방어 기법에 대한 설명은 OWASP 문서나 Acunetix 문서를 참고하자.
Security Level: low
이번 실습에서는 업로드하는 파일에 따라 요청의 바디가 달라지기 때문에 실습을 진행하는 동안에는 모두 동일한 파일(정상 파일, 악성 파일)을 사용한다. 일반 텍스트 파일을 업로드할 때 다음과 같은 요청이 전송된다.
POST /dvwa/vulnerabilities/upload/ HTTP/1.1
Host: 192.168.56.103
Content-Length: 493
Cache-Control: max-age=0
Origin: http://192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAP896TqC8pvk8utr
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/upload/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=low; PHPSESSID=fc1ee5a34584b780737247b0d3e06dc9
Connection: close
------WebKitFormBoundaryAP896TqC8pvk8utr
Content-Disposition: form-data; name="MAX_FILE_SIZE"
100000
------WebKitFormBoundaryAP896TqC8pvk8utr
Content-Disposition: form-data; name="uploaded"; filename="nether tp.txt"
Content-Type: text/plain
/execute as Kwonkyu in minecraft:the_nether run teleport 390 80 451
/xp add/set/query Kwonkyu 30
------WebKitFormBoundaryAP896TqC8pvk8utr
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryAP896TqC8pvk8utr--
파일 전송이기 때문에 POST 메서드가 사용되며 파일의 내용이 바디에 기재되어 있다. Content-Type 헤더를 보면 multipart/form-data 속성과 boundary 속성이 포함되어 있는데 이는 HTML form에서 데이터를 POST로 전송할 때 사용하는 방식으로 폼 데이터(여기서는 파일)가 여러 부분(multipart)으로 나뉘어 전송되며 '----WebKitFormBoundaryAP896TqC8pvk8utr'로 구분된다는 것을 의미한다. 아이디와 비밀번호로 로그인할 때 많이 보던 'id=admin&pw=password' 같은 바디는 application/x-www-form-urlencoded 속성으로 지정된다.
어쨌든 위처럼 boundary로 나뉜 POST 바디를 보면 Content-Disposition이라는 속성이 붙어 있는 것을 볼 수 있는데 이는 전송되는 리소스가 브라우저에 inline 되어야 하는 웹페이지 또는 그 일부인지, 아니면 다운로드되거나 로컬에 저장되는 용도인지 알려주는 헤더다. 현재 폼의 전송 방식인 multipart/form-data의 경우 항상 첫 번째 값으로 'form-data'를 가지며 이후 name이나 filename 속성만을 추가적으로 가질 수 있다. 지금 boundary로 나뉜 각 파트는 HTML 폼 내부의 각 input 태그에서 전송된 값들을 담고 있는데 첫번째 part의 경우 MAX_FILE_SIZE 란 이름의 값으로 100000을 가지고 있다. 이는 실습 페이지에서 못 본 것 같은데 어디 있었을까? 소스를 확인해보면 아래처럼 숨어 있는 것을 볼 수 있다.
간단히 말해서 input 태그의 name이 HTTP 요청에서 Content-Disposition의 name 속성으로 전달되며 해당 part의 데이터가 input 태그의 value가 된다고 보면 된다. 그래서 두 번째 part에서는 file 타입의 input을 담고 있으며 업로드된 파일의 이름(filename)과 파일의 내용("/execute as Kwonkyu in ..." 문자열)이 담겨있으며 마지막 part에는 name, value 속성 모두 Upload인 input 태그가 보낸 값이 담겨있는 것을 볼 수 있다.
그렇다면 이렇게 업로드된 파일로 뭘 할 수 있을까? 아무리 악성코드라도 그냥 서버에 저장되기만 한다면 아무런 영향을 끼치지 못하기 때문에 직접적으로든 간접적으로든 이를 실행시켜줘야 한다. 이 파일을 어떻게 참조할 수 있을까? 이는 업로드 후 출력되는 텍스트에서 추측할 수 있다.
현재 실습 페이지의 주소는 "/dvwa/vulnerabilities/upload/(index.php)"다. 파일이 업로드된 경로를 보면 "../../hackable/uploads/nether tp.txt"기 때문에 파일이 저장된 경로는 "/dvwa/hackable/uploads/nether tp.txt"다. 실제로 이곳으로 이동해보면 다음처럼 업로드된 파일을 읽을 수 있다.
지금은 텍스트 파일이기 때문에 단순히 내용을 출력하고 끝났지만 php 파일이나 자바스크립트 파일이 업로드됐다면 어땠을까? 이 경우 서버 측에서 코드가 실행되기 때문에 다음처럼 서버 정보 노출이나 추가 피해가 발생할 수 있다.
이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );
// Can we move the file to the upload folder?
if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) {
// No
echo '<pre>Your image was not uploaded.</pre>';
}
else {
// Yes!
echo "<pre>{$target_path} succesfully uploaded!</pre>";
}
}
?>
업로드된 파일이 저장될 경로를 생성한 후 아무런 확인 없이 move_uploaded_file() 함수를 호출하여 전송된 파일을 저장하고 있다.
Security Level: medium
이 단계에서는 이전과 동일한 요청이 전송된다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );
// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_type = $_FILES[ 'uploaded' ][ 'type' ];
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
// Is it an image?
if( ( $uploaded_type == "image/jpeg" || $uploaded_type == "image/png" ) &&
( $uploaded_size < 100000 ) ) {
// Can we move the file to the upload folder?
if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) {
// No
echo '<pre>Your image was not uploaded.</pre>';
}
else {
// Yes!
echo "<pre>{$target_path} succesfully uploaded!</pre>";
}
}
else {
// Invalid file
echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>';
}
}
?>
이번에는 업로드된 파일에 대한 정보를 담고 있는 변수의 name, type, size 속성을 참조하여 그중 mime-type과 크기를 확인하고 있다. 'image/jpeg'나 'image/png'만 받아들이고 있기 때문에 jpg, png 그림 파일만 받는다는 것인데 이 조건을 우회할 수 있을까? 이는 Low 단계에서 텍스트 파일을 전송했을 때 파일을 업로드하는 input 태그의 part에 명시된 Content-Type을 수정하여 우회할 수 있다.
이후 medium 단계와 high 단계에서 적절한 기법을 사용하여 우회했음에도 불구하고 이미지가 업로드되지 않는 경우가 있는데 이는 보통 크기 제한(100000)에 걸린 것이다. 이는 서버측에서 디스크 공간 절약을 위해 제한을 걸어둔 것이기 때문에 작은 크기의 이미지 파일(200 * 200 등)로 실습하도록 하자.
Security Level: high
이 단계에서는 이전과 동일한 요청이 전송된다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );
// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_ext = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
$uploaded_tmp = $_FILES[ 'uploaded' ][ 'tmp_name' ];
// Is it an image?
if( ( strtolower( $uploaded_ext ) == "jpg" || strtolower( $uploaded_ext ) == "jpeg" || strtolower( $uploaded_ext ) == "png" ) &&
( $uploaded_size < 100000 ) &&
getimagesize( $uploaded_tmp ) ) {
// Can we move the file to the upload folder?
if( !move_uploaded_file( $uploaded_tmp, $target_path ) ) {
// No
echo '<pre>Your image was not uploaded.</pre>';
}
else {
// Yes!
echo "<pre>{$target_path} succesfully uploaded!</pre>";
}
}
else {
// Invalid file
echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>';
}
}
?>
이 단계에서는 파일의 mime-type 뿐 아니라 확장자도 검사하고 있다. 그리고 getimagesize()란 함수를 호출하고 있는데 이는 해당 파일이 이미지 파일일 경우 이미지의 가로길이, 세로 길이나 이미지 타입 등이 배열로 반환된다. 만약 이미지 파일이 아니라면 false를 반환하기 때문에 파일 업로드가 실패한다. 현재 실습에서는 이미지 파일만 업로드하는 것이 원래 목표기 때문에 이 함수를 사용하여 업로드된 파일이 이미지 파일인지 검사하고 있다. 하지만 php 문서에서도 이 함수를 이런 용도로 사용하지 말라고 하고 있기 때문에 이는 충분히 우회할 수 있다.
Security Level: impossible
이 단계에서는 이전과 같은 요청이 전송된다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_POST[ 'Upload' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_ext = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
$uploaded_type = $_FILES[ 'uploaded' ][ 'type' ];
$uploaded_tmp = $_FILES[ 'uploaded' ][ 'tmp_name' ];
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . 'hackable/uploads/';
//$target_file = basename( $uploaded_name, '.' . $uploaded_ext ) . '-';
$target_file = md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext;
$temp_file = ( ( ini_get( 'upload_tmp_dir' ) == '' ) ? ( sys_get_temp_dir() ) : ( ini_get( 'upload_tmp_dir' ) ) );
$temp_file .= DIRECTORY_SEPARATOR . md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext;
// Is it an image?
if( ( strtolower( $uploaded_ext ) == 'jpg' || strtolower( $uploaded_ext ) == 'jpeg' || strtolower( $uploaded_ext ) == 'png' ) &&
( $uploaded_size < 100000 ) &&
( $uploaded_type == 'image/jpeg' || $uploaded_type == 'image/png' ) &&
getimagesize( $uploaded_tmp ) ) {
// Strip any metadata, by re-encoding image (Note, using php-Imagick is recommended over php-GD)
if( $uploaded_type == 'image/jpeg' ) {
$img = imagecreatefromjpeg( $uploaded_tmp );
imagejpeg( $img, $temp_file, 100);
}
else {
$img = imagecreatefrompng( $uploaded_tmp );
imagepng( $img, $temp_file, 9);
}
imagedestroy( $img );
// Can we move the file to the web root from the temp folder?
if( rename( $temp_file, ( getcwd() . DIRECTORY_SEPARATOR . $target_path . $target_file ) ) ) {
// Yes!
echo "<pre><a href='${target_path}${target_file}'>${target_file}</a> succesfully uploaded!</pre>";
}
else {
// No
echo '<pre>Your image was not uploaded.</pre>';
}
// Delete any temp files
if( file_exists( $temp_file ) )
unlink( $temp_file );
}
else {
// Invalid file
echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>';
}
}
// Generate Anti-CSRF token
generateSessionToken();
?>
이전 단계들에서는 적용되지 않았던 CSRF 토큰을 사용하고 있다. 그리고 업로드된 파일의 이름, 확장자, 타입, 크기, 임시 파일 이름 등 다양한 정보를 기반으로 File Upload 공격을 방지하고 있다. 먼저 공격자가 업로드된 파일을 찾을 수 없도록 uniqid() 함수를 이용하여 생성된 랜덤 문자열을 파일 이름에 붙인다. 이후 시스템 설정에서 임시 파일 디렉터리가 있는지 확인한 후 해당 디렉터리에 파일을 옮기는데 이때 md5() 함수로 파일 이름을 해시해서 더 찾기 힘들도록 하고 있다.
그리고 파일의 mime-type과 확장자를 검사하는데 다른 난이도와 다른 점은 업로드된 파일을 기반으로 서버에서 직접 imagecreatefromjpeg(), imagecreatefrompng() 함수를 호출하여 이미지 리소스를 생성하는 것이다. 이렇게 생성된 이미지는 imagejpeg(), imagepng() 함수로 새로운 이미지로 만들어져 임시 파일에 저장된다. 즉 사용자가 업로드한 파일을 기반으로 새 이미지를 만들어서 원본 대신 이를 저장하는 것이며 이 경우 사용자가 의도적이었든 비의도적이었든 원래 파일에 남겨놨던 메타데이터들이 모두 버려지게 된다.
이전까지 사용하던 move_uploaded_file() 함수 대신 rename() 함수를 사용하여 이 임시 파일을 실제로 파일들이 저장될 디렉터리로 옮길 수 있는지 확인하고 있다. 동일한 이름의 파일이 존재해도 이동 시 그냥 덮어씌워 버리는 move_uploaded_file() 함수와 달리 이름 변경(또는 이동. 리눅스의 mv 명령을 생각하면 된다) 함수를 통해 파일을 옮기는 것을 '시도'하여 만약 파일이 이미 존재한다면 에러 메시지와 함께 파일을 덮어 씌우지 않고 있다. 이후 임시 파일은 unlink() 함수 호출을 통해 삭제함으로써 사용자가 업로드한 파일이 웹 서버에 더 이상 남아있지 않도록 한다.
이미지 파일 하나 업로드하는데 왜 이렇게 많은 코드가 필요한지 싶지만 알 수 없는 사용자가 업로드한 알 수 없는 파일을 서버 내로 집어넣고 있는 만큼 보안에 만전을 기해야 하는 것은 당연할 것이다.
'프로젝트 > DVWA 실습' 카테고리의 다른 글
DVWA 실습 #6-2 - File Upload(medium) (0) | 2021.01.14 |
---|---|
DVWA 실습 #6-1 - File Upload(low) (0) | 2021.01.14 |
DVWA 실습 #5-3 - File Inclusion(high) (0) | 2021.01.12 |
DVWA 실습 #5-2 - File Inclusion(medium) (0) | 2021.01.12 |
DVWA 실습 #5-1 - File Inclusion(low) (0) | 2021.01.12 |