본문 바로가기

CTF

[BYUCTF 2023] Notes (미해결)

<문제>

 

 

 

 

 

<풀이>

 

 

1. Server 페이지 기능과 코드 확인

우선 Server 링크로 접근해 페이지와 코드를 확인해보았다.

 

server.py 코드의 상단을 먼저 살펴보면 다음과 같다.

FLAG = open("flag.txt", "r").read()
SECRET = open("secret.txt", "r").read()
users = [{'username':'admin','password':SECRET}] # NEVER DO THIS IN PRODUCTION fyi
notes = [{
    "note":FLAG,
    "user":"admin",
    "id":"00000000000000000000000000000000",
    "shared":[]
}]
csrf_tokens = []

users 배열에는 admin 계정을 포함한 사용자 계정 정보를 저장한다.

notes 배열에는 admin 유저 소유로 FLAG 값을 note로 가지는 노트를 포함해 사용자가 생성한 노트를 저장한다.

notes의 요소는 note, user, id, shared 필드를 가진다.

 

url이 /login으로 끝나는 login 페이지가 먼저 뜬다. 해당 엔드포인트에 대한 코드를 살펴보았다.

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        if 'username' not in request.form or 'password' not in request.form:
            return 'Username and password cannot be empty'
        
        if not isinstance(request.form['username'], str) or not isinstance(request.form['password'], str):
            return 'Username and password must be strings'    
        
        if (len(request.form['username']) < 9 or len(request.form['password']) < 9) and request.form['username'] != 'admin':
            return 'Username and password must be at least 9 characters'
        
        for user_obj in users:
            if user_obj['username'] == request.form['username'] and user_obj['password'] == request.form['password']:
                session['username'] = request.form['username']
                return redirect('/')
            
        return 'Incorrect username or password'
    return '''
    <h1>Login</h1>
        <form method="post">
            <p><label for="username">Username</label>
            <input id="username" type=text name=username>
            <p><label for="password">Password</label>
            <input id="password" type=text name=password>
            <p><input id="formsubmit" type=submit value=Login>
        </form>

        <a href="/register"><h3>Register here</h3></a>
    '''

username과 password를 입력 받아 로그인을 하는 기능을 하고 있다. 특이한 점은 username이 사용자가 입력한 request.form['username']과 일치하고 password도 request.form['username']과 일치하는 경우 로그인에 성공한다는 점이다. 따라서 로그인할 때 username 입력란과 password 입력란 모두에 username을 입력해야 하는 것을 알 수 있다.

 

Register here 링크를 클릭하여 Register 페이지에도 방문해보았다.

username과 password 입력란이 있다. 해당 엔트포인트에 대한 코드를 살펴보았다.

@app.route('/register', methods=['GET', 'POST'])
def register():
    global users
    
    if len(users) >= 150:
        users = []


    if request.method == 'POST':
        if 'username' not in request.form or 'password' not in request.form:
            return 'Username and password cannot be empty'
        
        if not isinstance(request.form['username'], str) or not isinstance(request.form['password'], str):
            return 'Username and password must be strings'

        if len(request.form['username']) < 9 or len(request.form['password']) < 9:
            return 'Username and password must be at least 9 characters'

        for user_obj in users:
            if user_obj['username'] == request.form['username']:
                return 'Username already taken'
        
        users.append({"username":request.form['username'],"password":request.form['username']})
        return redirect('/login')
    
    return '''
        <h1>Register</h1>
        <form method="post">
            <p><label for="username">Username</label>
            <input type=text name=username>
            <p><label for="password">Password</label>
            <input type=text name=password>
            <p><input type=submit value=Register>
        </form>

        <a href="/login"><h3>Login here</h3></a>
    '''

username과 password 모두 9자리 이상이 되어야 함을 알 수 있다. usename을 임의로 'pineapple'로 하고 password를 '123456789'로 하여 사용자를 등록했다.

 

등록한 사용자 정보를 이용해 pineapple 계정으로 로그인하였다.

앞서 살펴본 대로 username에는 등록할 때 입력한 username를 쓰고, password는 등록할 때 입력한 password 값과 상관없이 username을 입력해서 로그인에 성공했다. 로그인에 성공하면 아래와 같은 home 페이지가 뜬다.

view notes 링크로 들어가보았다.

 

note 엔드포인트에 해당하는 코드를 살펴보았다.

 

@app.route('/notes', methods=['GET'])
def view_notes():
    if 'username' not in session:
        return redirect('/login')
    
    user_notes = []
    for note in notes:
        if note['user'] == session['username']:
            user_notes.append(note)
    
    page = "<h1>Notes</h1>"
    for note in user_notes:
        page += f"<p>{html.escape(note['note'],quote=True)}</p>"
        page += f"<p>{html.escape(note['id'],quote=True)}</p>"

    shared_notes = []
    for note in notes:
        if session['username'] in note['shared']:
            shared_notes.append(note)

    page += "<h1>Shared Notes</h1>"
    for note in shared_notes:
        page += f"<p>{html.escape(note['note'],quote=True)}</p>"
        page += f"<p>{html.escape(note['id'],quote=True)}</p>"

    page += '''
        <a href="/create"><h3>Create note</h3></a>
        <a href="/share"><h3>Share note</h3></a>
        <a href="/logout"><h3>Logout</h3></a>
    '''

    return page

로그인하여 session에 저장되어있는 username과 note 배열에 있는 user값이 일치하는 요소를 하나씩 찾아서 담은 user_notes를 생성한다.

그리고 shared_notes라는 배열을 생성하여 note의 shared필드에 로그인한 username이 들어있으면 해당 note를 shared_notes 배열에 저장한다.

생성된 user_notes와 shared_notes 배열에 들어있는 note를 페이지에 출력한다.

 

 

그 다음으로는 create note 링크로 들어가보았다.

먼저 코드를 살펴보았다.

@app.route('/create', methods=['GET', 'POST'])
def create():
    global notes
    
    if len(notes) > 200:
        notes = []

    if 'username' not in session:
        return redirect('/login')
    
    if request.method == 'POST':
        if 'note' not in request.form:
            return 'note cannot be empty'
        
        if not isinstance(request.form['note'], str):
            return 'note must be a string'
        
        if len(request.form['note']) > 100:
            return 'note size is max 100'
        
        notes.append({"note":request.form['note'],"user":session['username'],"id":secrets.token_hex(16),"shared":[]})
        return redirect('/notes')

    return '''
        <h1>Create note</h1>
        <form method="post">
            <p><label for="note">Note Description</label>
            <input type=text name=note>
            <p><input type=submit value=Create>
        </form>

        <a href="/notes"><h3>View notes</h3></a>
    '''

사용자의 입력값으로 note를 받아 로그인된 username을 user로 하여 note를 생성하고 있다.

 

 'hello'라는 내용으로 노트를 생성해보았다.

더불어 Stored XSS 취약점이 발생하는지 확인하기 위해 '<script>alert('pineapple');</script>'이라는 내용으로도 노트를 생성해보았다. 노트 생성 후 업로드된 노트 목록을 살펴보면 아래와 같은 내용을 확인할 수 있다.

알 수 없는 문자열과 내가 입력한 노트가 함께 쓰여 있는 것을 확인할 수 있다.

 

Share note의 코드도 살펴보았다.

@app.route('/share', methods=['GET', 'POST'])
def share():
    global csrf_tokens
    
    if len(csrf_tokens) > 200:
        csrf_tokens = []

    if 'username' not in session:
        return redirect('/login')
    
    if request.method == 'POST':
        if 'note_id' not in request.form or 'user' not in request.form or 'csrf_token' not in request.form:
            return 'note_id cannot be empty'
        
        if not isinstance(request.form['note_id'], str) or not isinstance(request.form['user'], str) or not isinstance(request.form['csrf_token'], str):
            return 'All parameters must be a string'
        
        if request.form['csrf_token'] not in csrf_tokens:
            return 'CSRF token is invalid'
        
        if len(request.form['note_id']) != 32:
            return 'note_id must be 32 characters'
        
        note_exists = False
        for note in notes:
            if note['id'] == request.form['note_id']:
                note_exists = True
                break
        
        if not note_exists:
            return 'note_id is invalid'
        
        user_exists = False
        for user in users:
            if user['username'] == request.form['user']:
                user_exists = True
                break
        
        if not user_exists:
            return 'User does not exist'
        
        for note in notes:
            if note['id'] == request.form['note_id'] and note['user'] == session['username']:
                note['shared'].append(request.form['user'])
                return redirect('/notes')
            
        return 'You don\'t own this note'
    
    token = secrets.token_hex(32)
    csrf_tokens.append(token)

    return f'''
        <h1>Share note</h1>
        <form method="post">
            <p><label for="note_id">Note ID</label>
            <input type=text name=note_id>
            <p><label for="user">User</label>
            <input type=text name=user>
            <p><input type=submit value=Share>
            <input type=hidden name=csrf_token value={token}>
        </form>

        <a href="/notes"><h3>View notes</h3></a>
    '''

note_id와 user를 입력받아서 공유되는 노트를 생성하는 코드이다. 이용자가 입력한 note id값이 존재하는 notes 리스트 안에 있는 note와 일치하고 해당 note의 user가 로그인되어있는 username과 일치하면 즉, 공유하려는 note의 주인이 로그인 되어있는 사용자와 일치하면 note의 shared 필드에 입력한 user명을 넣어 note를 공유상태로 전환한다.

 

2. Admin bot 페이지와 코드 확인

먼저 페이지에 접속해보았다.

'admin bot이 페이지를 방문하게 하라'는 문구와 함께 입력창이 뜨는 것을 볼 수 있다.

 

코드를 살펴보았다.

const visitUrl = async (url) => {

    let browser =
            await puppeteer.launch({
                headless: "new",
                pipe: true,
                dumpio: true,

                // headless chrome in docker is not a picnic
                args: [
                    '--no-sandbox',
                    '--disable-gpu',
                    '--disable-software-rasterizer',
                    '--disable-dev-shm-usage',
                    '--disable-setuid-sandbox',
                    '--js-flags=--noexpose_wasm,--jitless'
                ]
            })

    try {
        const page = await browser.newPage()

        try {
            await page.setUserAgent('puppeteer');
            
            // login
            await page.goto('http://127.0.0.1:1337/login', { timeout: 5000, waitUntil: 'networkidle2' })
            await page.type('#username', 'admin');
            await page.type('#password', SECRET);
            await Promise.all([
                page.click('#formsubmit'),
                page.waitForNavigation({ waitUntil: 'networkidle0' }),
            ]);

            // visit the page
            await page.goto(url, { timeout: 5000, waitUntil: 'networkidle2' })
        } finally {
            await page.close()
        }
    }
    finally {
        browser.close()
        return
    }
}

admin 계정으로 로그인하는듯한 코드를 살펴볼 수 있다. 그리고 입력한 url에 해당하는 페이지로 방문하는 코드도 살펴볼 수 있다. 코드를 맞게 해석한 것인지 모르겠지만, admin으로 로그인한 상태에서 url에 접속하는 기능을 하는 페이지인 것 같다.

 

url에 임의의 url을 입력해보았다.

Go 버튼을 클릭하면 곧 Visited Page라는 알림창이 뜬다.

해당 url에 session이 admin인 채로 방문했다는 의미인 것 같다.

 

3. 취약점 확인

admin bot을 통해 /share 엔드포인트에 방문하는데, 그때 note_id를 FLAG 값을 가진 note의 id 인 00000000000000000000000000000000으로 하고, user를 note를 공유할 user, pineapple로 하면 admin의 노트를 pineapple이 공유받을 수 있을 것 같다는 예측을 해본다.

그런데 admin의 노트를 공유하려면 session 값에 admin이 들어있어야 한다.

admin으로 직접 로그인할 수는 없으므로 admin으로 로그인한 상태를 가지는 것으로 추측되는 admin bot을 이용해 접근한다.

 

이와 같은 시나리오를 토대로 admin bot  페이지에서 url을 생성해보았다.

위와 같은 url을 작성해서 페이지에 방문해보았으나, 다시 pineapple의 note 리스트를 확인해봤을 때 Shared Note 목록에 뜨는 것이 없었다.

 

 

 

 

여기까지 공격을 시도해보고 문제풀이를 종료하였다.

'CTF' 카테고리의 다른 글

[HSpace CTF] HSpace Free Board  (1) 2023.09.01
[TJCTF 2023]swill-squill  (0) 2023.05.28
[Vishwa CTF] Eeezzy(미해결)  (0) 2023.04.02
[LINE CTF 2023] old pal(미해결)  (0) 2023.03.27
[LINE CTF 2023] baby simple gocurl  (0) 2023.03.27