첨부 파일 다운로드 코드가 이름을 그대로 받고 있었다.
$file = $_GET['file'];
readfile('/uploads/' . $file);
Table of contents
Open Table of contents
폴더 밖으로 나갔다
주소에 ../ 를 넣어 봤더니 config.php 가 내려왔다. 앞에 붙인 /uploads/ 가 상쇄돼 그 폴더 밖을 가리키게 된 것이다.
먼저 str_replace 로 그 표기를 지울까 했다. 그런데 우회가 여럿이었다. %2e%2e%2f 처럼 인코딩된 형태가 있다. ....// 는 가운데를 지우면 다시 ../ 가 된다. 절대 경로를 넣는 방법도 있다.
realpath 로 경로를 풀어 보는 방법도 있다. 그 결과가 /uploads/ 로 시작하는지만 보면 된다. 목록을 늘리는 대신 해소된 결과를 보는 방식이라 낫다. 다만 없는 파일에는 false 가 오니 그것을 따로 다뤄야 한다.
이름을 안 받기로 했다
방향을 바꿔서 이름 자체를 받지 않기로 했다.
$id = (int) $_GET['id'];
$row = $model->get_file($id);
readfile('/uploads/' . $row->stored_name);
(int) 로 캐스팅한 번호만 받는다. 실제 이름은 stored_name 에서 가져와 readfile 에 넘긴다. 이러면 우리가 저장한 이름만 쓰인다. 무엇을 거를지 고민하는 대신 들어올 값의 범위를 좁힌 것이다.
원래 이름은 original_name 에 따로 저장했다. stored_name 은 uniqid 로 만든 값이고 화면과 다운로드에는 원래 이름이 보여야 한다.
원래 이름도 사용자 입력이었다
내려줄 때 Content-Disposition 에 원래 이름을 넣으면서 걸리는 것이 있었다. 그 이름도 사용자가 정한 값이다. 따옴표나 줄바꿈이 들어가면 헤더가 거기서 끊기거나 한 줄이 더 생긴다.
$name = preg_replace('/[^\w가-힣.\-]/u', '_', $row->original_name);
preg_replace 에 막을 문자를 나열하는 대신 남길 문자를 나열했다. 앞의 방식은 하나를 빠뜨리면 뚫리고 뒤의 방식은 빠뜨려도 그 문자가 안 들어갈 뿐이다.
한글 이름은 또 다른 문제였다. Content-Disposition 의 filename 에 그냥 넣으면 브라우저마다 다르게 읽는다. RFC 6266 이 정한 filename* 로 UTF-8'' 뒤에 rawurlencode 한 값을 주고 옛 브라우저를 위해 filename 도 함께 뒀다. Content-Length 와 Content-Type 도 같이 넣어야 받는 쪽이 진행률을 보여 준다.
누구 파일인지와 공개 여부
또 하나 빠진 것이 있었다. get_file 이 WHERE file_no = ? 하나로 끝나서 번호만 바꾸면 남의 첨부가 내려왔다.
$row = $model->get_file_for_user($id, $userId);
if (!$row) show_404();
다만 공지 첨부처럼 누구나 볼 수 있어야 하는 파일도 있었다. is_public 컬럼을 두고 공개가 아니면 소유자만 보게 했다. 새로 올라오는 파일은 기본값을 비공개로 뒀다. 기본이 공개면 실수로 공개된다. 기본이 비공개면 실수로 안 보인다. 안 보이는 쪽은 바로 문의가 오지만 공개된 쪽은 아무도 모른다.
마지막으로 uploads/ 에 .htaccess 를 두고 deny from all 로 직접 열리는 것을 막았다. 이게 없으면 stored_name 만 알면 열리고 권한 검사를 통째로 건너뛴다. 반드시 우리 코드를 지나야 위의 확인이 걸린다.
정리
- 파일 이름을 그대로 받으면
../로 상위 폴더에 나갈 수 있다 str_replace로 막으면%2e%2e%2f와....//같은 우회가 있다realpath로 풀어 시작 경로를 보는 방법은 목록을 늘리지 않아 낫다- 이름을 안 받고
(int)번호로 받으면 들어올 값의 범위가 좁아진다 stored_name과original_name을 따로 둔다Content-Disposition에 넣을 이름도 사용자 입력이라 거른다- 한글 이름은 RFC 6266 의
filename*에rawurlencode한 값을 준다 preg_replace는 막을 것이 아니라 남길 것을 적는다- 누구 파일인지 확인하고 공개 여부는 컬럼으로 둔다
is_public기본값을 비공개로 두면 실수가 드러난다uploads/에deny from all을 두면 반드시 코드를 지난다