Skip to content
isdnetworks
Go back

파일 이름이 사용자에게서 왔다

첨부 파일 다운로드 코드가 이름을 그대로 받고 있었다.

$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_nameuniqid 로 만든 값이고 화면과 다운로드에는 원래 이름이 보여야 한다.

원래 이름도 사용자 입력이었다

내려줄 때 Content-Disposition 에 원래 이름을 넣으면서 걸리는 것이 있었다. 그 이름도 사용자가 정한 값이다. 따옴표나 줄바꿈이 들어가면 헤더가 거기서 끊기거나 한 줄이 더 생긴다.

$name = preg_replace('/[^\w가-힣.\-]/u', '_', $row->original_name);

preg_replace 에 막을 문자를 나열하는 대신 남길 문자를 나열했다. 앞의 방식은 하나를 빠뜨리면 뚫리고 뒤의 방식은 빠뜨려도 그 문자가 안 들어갈 뿐이다.

한글 이름은 또 다른 문제였다. Content-Dispositionfilename 에 그냥 넣으면 브라우저마다 다르게 읽는다. RFC 6266 이 정한 filename*UTF-8'' 뒤에 rawurlencode 한 값을 주고 옛 브라우저를 위해 filename 도 함께 뒀다. Content-LengthContent-Type 도 같이 넣어야 받는 쪽이 진행률을 보여 준다.

누구 파일인지와 공개 여부

또 하나 빠진 것이 있었다. get_fileWHERE file_no = ? 하나로 끝나서 번호만 바꾸면 남의 첨부가 내려왔다.

$row = $model->get_file_for_user($id, $userId);
if (!$row) show_404();

다만 공지 첨부처럼 누구나 볼 수 있어야 하는 파일도 있었다. is_public 컬럼을 두고 공개가 아니면 소유자만 보게 했다. 새로 올라오는 파일은 기본값을 비공개로 뒀다. 기본이 공개면 실수로 공개된다. 기본이 비공개면 실수로 안 보인다. 안 보이는 쪽은 바로 문의가 오지만 공개된 쪽은 아무도 모른다.

마지막으로 uploads/.htaccess 를 두고 deny from all 로 직접 열리는 것을 막았다. 이게 없으면 stored_name 만 알면 열리고 권한 검사를 통째로 건너뛴다. 반드시 우리 코드를 지나야 위의 확인이 걸린다.

정리


Share this post on:

Previous Post
스크립트가 중간에 멈췄고, 백업본이 사라져 있었다
Next Post
예약 작업이 두 번 돌았다