yon11b

[SK 쉴더스 루키즈] 파일 다운로드 취약점 실습 문제(1~4번)와 대응방안 본문

보안/SK 쉴더스 루키즈

[SK 쉴더스 루키즈] 파일 다운로드 취약점 실습 문제(1~4번)와 대응방안

yon11b 2026. 7. 1. 23:15
반응형

1번: 단순 path traversal

/answer1/down.txt 다운 받아라.

 

아무 파일이나 업로드해서 게시글을 작성해준다.

그리고 그 게시글을 조회했을 때 해당 파일을 어느 경로에서 불러오는지 확인해보자.

서버가 올라간 파일을 어디서 불러오는지 그 경로를 보면 answer1/down.txt도 불러올 수 있을 것 같다.

 

나는 down.txt 이름으로 된 파일을 올렸다.

https://lab.eqst.co.kr:8086/exam26/down/download2.php?file_path=/lib/file/faq/down.txt 여기서 불러온다.

 

여기서 file_path를 조작해보자.

 

첫번째 시도

내가 원하는 파일 경로는 /answer1/down.txt이므로 그냥 무지성으로 file_path 자리에 넣어본다.

 

다운을 받아봤더니 0KB로 그냥 빈 파일만 준다… 경로가 잘못되었나보다.

 

어떻게 공격할지 생각하기

나는 file_path 다음의 경로가 실제 서버내부에서 어떻게 붙는지 모른다.

/var/path/answer1/down.txt 이렇게 붙을 수도 있고,

/var/a/b/c/answer1/down.txt 이렇게 붙을 수도 있다.

클라이언트 입장에서는 이걸 알 수가 없다.

 

그런데 우리 문제에서는 파일이 루트 경로에 존재한다.(/answer~로 시작하니까)

그럼 우리는 루트까지 일단 올라가야 한다. => ../로 계속 올라가야함.

 

실행하기

file_path=../../answer1/down.txt

file_path=../../../answer1/down.txt

이런식으로 하나씩 ../을 추가해서 요청을 보내고 응답이 오는지 확인해보면 된다.

 

정답: ../../../../../../../answer1/down.txt

 

실제로 다운을 받아 확인해봐도 보인다.

2번: 블랙리스트 필터링

1번과 똑같이 answer2/down.txt를 구하는 문제이다.

그런데 이번에는 아무리 ../로 올라가도 파일이 안 보인다.

응답을 보면 다 content-length가 0이다.

 

이런 경우, 이유를 다음과 같이 추측해볼 수 있다.

1. 해당 경로에 파일이 없다. -> ../을 계속 해봤는데도 안 나왔음. 이건 아닐듯

2. ../이 필터링 되어 상위경로로 이동되지 않았다. -> 일리있음

3. 파일은 있는데 0바이트이다. -> 이럴리가 없음. 문제인데.

 

필터링 처리하는 방법에는 여러가지가 있겠지만 여기서는 ../이 보이면 제거를 한다.

$file_path = str_replace("../", "", $_GET['file_path']);

코드로 나타내면 이런 느낌

 

필터링 우회 방법

-> ....//으로 필터링 걸리는 ../을 가운데에 두고 또다른 ../로 크게 감싸준다.

script 문자열이 필터링 걸렸을 때 scriscriptpt 써주는 것과 같은 원리!

 

GET /exam27/down/download2.php?file_path=....//....//....//....//....//....//....//....//answer2/down.txt HTTP/1.1

3번: 더블인코딩으로 해결

/answer3/down.txt를 다운 받는 문제.

 

이번에는 ....// 을 해줘도 안 된다. 이런 경우 더블인코딩을 생각해볼 수 있다.

 

서버 코드가 다음과 같다고 추측해볼 수 있다.

$file_path = $_GET['file_path'];          // 이미 1차 디코딩된 값: %2e%2e%2f...
if (strpos($file_path, "../") !== false) {
    exit("blocked");
}

$file_path = urldecode($file_path);       // 여기서 ../ 로 변함
readfile($file_path);

 

1차 URL 디코딩:

%252e%252e%252f
→ %2e%2e%2f
 

이 상태에서 필터가 검사하면 문자열에 ../가 없다.

%2e%2e%2f
 

그래서 ../ 필터를 통과한다.

그 다음 서버 코드 어딘가에서 한 번 더 디코딩된다.

%2e%2e%2f
→ ../
 

결국 실제 파일 접근 시점에는 이렇게 된다.

../../../../answer3/down.txt
 

그래서 성공한다.

 

정답

GET /exam28/down/download2.php?file_path=%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fanswer3%252fdown.txt HTTP/1.1

4번: 단순 path traversal

파일 다운로드 취약점을 이용하여 '/data/files/qna/a.txt'를 다운받아 정답을 확인하시오.

file_path=../data/files/qna/20260701/2c9a50fc-e47a-4caf-88ba-e24d19a4109e.png

어떤 파일 다운로드 요청을 잡아서 봤더니 경로가 이렇게 생겼다

../data/files/qna/날짜/파일명

 

이 경로가 서버 기준 상대경로이므로 이 경로를 참고 + 우리가 구해야 하는 파일 경로를 잘 조합해서 막 넣어보면 된다.

../data/files/qna/a.txt

../data/files/qna/20260701/a.txt

...

성공!

 

대응방안: fileId만 받기

파일 다운로드에서 사용자가 주면 안 되는 값은 filePath다.

GET /download?filePath=../../../../answer/down.txt
 

이런 방식은 위험하다. 사용자가 서버 내부 경로를 조작할 수 있기 때문이다.

안전한 방식은 사용자가 fileId만 보내는 것이다.

GET /download?fileId=2
 

서버는 fileId로 DB를 조회한다.

 

node js 서버 코드 예시

app.get("/download", async (req, res) => {
  const fileId = req.query.fileId;

  const file = await db.files.findByPk(fileId);

  if (!file) {
    return res.status(404).send("File not found");
  }

  const filePath = path.join(__dirname, "uploads", file.storedName);

  return res.download(filePath, file.originalName);
});

사용자로부터 fileId를 받고 그걸 내부 DB테이블에서 매핑해서 실제 파일을 찾는 방식이다.

 

DB에는 이런 정보가 들어간다.

+----+---------------+-----------------------------+-----------------+-------+----------+
| id | original_name | stored_name                 | mime_type       | size  | owner_id |
+----+---------------+-----------------------------+-----------------+-------+----------+
|  2 | report.pdf    | 6f8a1c2e-91d3-4b7f.pdf      | application/pdf | 12345 |       10 |
+----+---------------+-----------------------------+-----------------+-------+----------+
 

 

여기서 originalName은 사용자가 업로드한 원래 파일명이다.

report.pdf
 

 

storedName은 서버에 실제 저장된 파일명이다.

6f8a1c2e-91d3-4b7f.pdf
 

 

다운로드할 때는 이렇게 된다.

사용자 요청
GET /download?fileId=2

서버 처리
1. fileId=2 받음
2. DB에서 2번 파일 조회
3. 권한 확인
4. uploads/6f8a1c2e-91d3-4b7f.pdf 파일 읽음
5. 사용자에게는 report.pdf 이름으로 다운로드시킴

 

Express의 multer 기능: 파일명 다르게 저장

const express = require("express");
const multer = require("multer");
const mysql = require("mysql2/promise");
const path = require("path");

const app = express();

const db = mysql.createPool({
  host: "localhost",
  user: "root",
  password: "password",
  database: "testdb"
});

// 업로드 저장 설정
const upload = multer({
  dest: "uploads/",
  limits: {
    fileSize: 10 * 1024 * 1024 // 10MB
  }
});

// 임시 로그인 사용자라고 가정
app.use((req, res, next) => {
  req.user = { id: 10 };
  next();
});

// 파일 업로드
app.post("/upload", upload.single("file"), async (req, res) => {
  try {
    const file = req.file;

    if (!file) {
      return res.status(400).send("파일이 없습니다.");
    }

    const [result] = await db.query(
      `
      INSERT INTO files
      (original_name, stored_name, storage_path, mime_type, size, owner_id)
      VALUES (?, ?, ?, ?, ?, ?)
      `,
      [
        file.originalname,   // 사용자가 올린 원래 파일명
        file.filename,       // multer가 만든 랜덤 파일명
        file.destination,    // uploads/
        file.mimetype,       // application/pdf 등
        file.size,           // 파일 크기
        req.user.id          // 업로드한 사용자
      ]
    );

    res.json({
      message: "업로드 성공",
      fileId: result.insertId
    });
  } catch (err) {
    console.error(err);
    res.status(500).send("서버 오류");
  }
});

// 파일 다운로드
app.get("/download/:id", async (req, res) => {
  try {
    const fileId = req.params.id;

    const [rows] = await db.query(
      "SELECT * FROM files WHERE id = ?",
      [fileId]
    );

    if (rows.length === 0) {
      return res.status(404).send("파일 없음");
    }

    const file = rows[0];

    if (file.owner_id !== req.user.id) {
      return res.status(403).send("권한 없음");
    }

    const uploadDir = path.resolve(__dirname, file.storage_path);
    const filePath = path.resolve(uploadDir, file.stored_name);

    if (!filePath.startsWith(uploadDir + path.sep)) {
      return res.status(400).send("잘못된 파일 경로");
    }

    res.download(filePath, file.original_name);
  } catch (err) {
    console.error(err);
    res.status(500).send("서버 오류");
  }
});

app.listen(3000, () => {
  console.log("server running on http://localhost:3000");
});

 

multer를 사용하면 사용자가 올린 파일명 / multer가 만든 랜덤 파일명을 따로 저장할 수 있다.

728x90