July 27

Writeup - firststeps

Цель: Найти флаг на CTF-задаче «Первые шаги»

Декодирование: «The early bee gets the honey» (Кто рано встаёт — того мёд ждёт) — leetspeak
Категория: Web/JavaScript reverse engineering (client-side CTF challenge)

Разведка

  • Открываем сайт https://firststeps-5w7im53y.avitoctf.ru
  • Видим игру «Первые шаги» — лабиринт на сотах, где нужно провести барсука-медоеда к флагу, обходя ловушки
  • Изучаем исходный код страницы — находим подключённый JavaScript-файл /assets/firststeps.js

Анализ JavaScript

В файле firststeps.js обнаруживаем ключевую функцию levelReward(level), которая генерирует часть флага для каждого уровня сложности:

Ключевые константы:

var FIXED_COLS = 15;
var FIXED_ROWS = 10;
var LEVEL_ORDER = ['easy', 'medium', 'hard'];
var LEVEL_REWARD_LENGTHS = [10, 9, 9];  // длина каждой части флага

// Массив зашифрованных данных для всех частей флага
var LEVEL_REWARD_DATA = [
  99,179,125,189,59,220,227,52,178,148,
  156,34,201,91,248,251,14,73,54,212,
  178,230,102,64,45,182,145,14,3,160
];

// Координаты "дырок" на игровом поле (используются для seed'а)
var FIXED_HOLE_COORDS = [[2,1],[7,1],[11,1],[4,2],[9,3],[13,3],
                         [1,4],[6,5],[11,5],[3,7],[8,8],[12,8]];

Функция генерации части флага

function levelReward(level){
  var index = LEVEL_ORDER.indexOf(level); // 0, 1, 2

  // Начальное seed-значение на основе индекса уровня и размеров поля
  var stream = (0x6d2b79f5 ^ Math.imul(index + 1, 0x9e3779b1)
                ^ (FIXED_COLS << 24) ^ (FIXED_ROWS << 16)) >>> 0;

  // Примешиваем координаты "дырок" в seed
  FIXED_HOLE_COORDS.forEach(function(pos, holeIndex){
    stream ^= Math.imul(cellKey(pos[0], pos[1], FIXED_COLS)
                + holeIndex + 1, 0x045d9f3b);
    stream = ((stream << 11) | (stream >>> 21)) >>> 0;
  });

  var reward = '';
  // lane определяет, какие байты из LEVEL_REWARD_DATA брать
  var lane = (index * 2 + 1) % LEVEL_ORDER.length;

  for (var i = 0; i < LEVEL_REWARD_LENGTHS[index]; i++){
    // xor-шифр: псевдослучайная последовательность на основе stream
    stream ^= stream << 13;
    stream ^= stream >>> 17;
    stream ^= stream << 5;
    stream >>>= 0;
    // Берём байт из LEVEL_REWARD_DATA и xor'им с младшим байтом stream
    reward += String.fromCharCode(
      LEVEL_REWARD_DATA[i * LEVEL_ORDER.length + lane] ^ (stream & 255)
    );
  }
  return reward;
}
  1. Алгоритм генерации:
    Вычисляется seed (stream) на основе номера уровня (0, 1, 2), размера поля (15×10) и координат «дырок»
  2. Для каждого символа флага применяется простой xor-шифр: берётся байт из предопределённого массива LEVEL_REWARD_DATA и xor'ится с псевдослучайной последовательностью, генерируемой через младший байт stream
  3. Lane (дорожка) определяет, какие элементы массива относятся к какому уровню:
  • easy (index 0) → lane 1 — элементы 1, 4, 7, 10, 13, 16, 19, 22, 25, 28
  • medium (index 1) → lane 0 — элементы 0, 3, 6, 9, 12, 15, 18, 21, 24
  • hard (index 2) → lane 2 — элементы 2, 5, 8, 11, 14, 17, 20, 23, 26

Эксплуатация

Флаг генерируется полностью на клиентской стороне. Нет необходимости проходить игру — достаточно скопировать функцию levelReward() и выполнить её в любой JavaScript-среде (браузерная консоль, Node.js).

Способ 1:Через консоль браузера

Открыть DevTools (F12) на странице https://firststeps-5w7im53y.avitoctf.ru
Выполнить в консоли:

var flag = '';
for (var l of ['easy', 'medium', 'hard']) flag += levelReward(l);
console.log('avito{' + flag + '}');

Способ 2: Node.js репликация

Создать файл compute_flag.js со скопированной функцией levelReward() и выполнить:

node compute_flag.js

Вывод

Уязвимость: Клиентская генерация флага — вся логика вычисления флага выполняется на стороне клиента в JavaScript. Любой пользователь может прочитать и воспроизвести эту логику, получив флаг без прохождения игры.
Рекомендация по исправлению: Флаг должен генерироваться и проверяться на серверной стороне. Клиент должен отправлять серверу доказательство прохождения уровня (например, найденный путь в лабиринте), а сервер — возвращать часть флага только при валидном решении.
Потенциальное улучшение: API-эндпоинт /api/flag?level=easy&proof=<path_hash>, который проверяет, действительно ли игрок прошёл уровень до конца, и возвращает только соответствующую часть флага.