Skip to content
Alexander Borzunov edited this page Mar 11, 2018 · 10 revisions

Руководство банка «Мечта» подозревает бывших сотрудников в установке бэкдора

  • Категория: Forensics
  • Стоимость: 700
  • Автор: Александр Борзунов
  • Репозиторий

Условие

На днях стало известно, что в начале декабря несколько back-end разработчиков и специалистов по анализу данных, работавших в банке «Мечта», практически одновременно уволились и выехали за границу России.

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

Решение

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

Проанализируем код сайта. Сперва введённая анкета преобразуется в словарь с числовыми значениями:

{'credit_amount': 557107, 'dependents': 2, 'duration': 1, 'education': 1, 'expense:alimony': 0, 'expense:car_service': 0, 'expense:credits': 0, 'expense:housing': 0, 'expense:insurance': 0, 'expense:other': 0, 'expense:taxes': 0, 'has_overdue_debts': 0, 'income:dividents': 0, 'income:employee_wage': 151679, 'income:other': 0, 'income:rent': 49988, 'income:state_wage': 176765, 'married': 0, 'missed_deadlines': 1, 'property:apartment': 0, 'property:car': 3728820, 'property:house': 3073060, 'purpose:capital': 0, 'purpose:car': 0, 'purpose:education': 1, 'purpose:other': 0, 'purpose:real_estate': 0, 'was_bankrupt': 0}

Затем этот словарь сортируется по ключам, после чего остаются только числовые значения:

[557107, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151679, 0, 49988, 176765, 0, 1, 0, 3728820, 3073060, 0, 0, 1, 0, 0, 0]

Массив с этими значениями передаётся в функцию model.predict, которая оценивает риски и вычисляет искомую ставку (например, если ставка равна 23% годовых, функция вернёт 1.23). Ниже в коде встречается переменная flag, которая подсказывает, что флаг будет выдан в качестве кода, который нужно сообщить сотрудникам банка.

Как же работает функция model.predict? Можно заметить, что модель является Python-объектом, загружаемым из файла model.pickle. В интерактивной консоли Python посмотрим, что это за объект:

>>> import pickle
>>> model = pickle.load(open('model.pickle', 'rb'))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'sklearn'

Просто так объект не загружается - нужно установить модуль sklearn:

>>> model = pickle.load(open('../flask/model.pickle', 'rb'))
>>> type(model)
<class 'sklearn.tree.tree.DecisionTreeRegressor'>

В файле находится решающее дерево - популярная модель, используемая в машинном обучении. Она представляет собой дерево, в каждой нелистовой вершине которого находится условие вида "i-й признак > x" (например, "сумма кредита > 100000"). Для каждой заполненной анкеты мы начинаем идти по дереву из корня (в зависимости от условия спускаясь влево или вправо) и в итоге приходим в лист, в котором записано значение, которая должна вернуть модель:

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

В данном случае нас интересует, существуют ли наборы условий, когда выдаваемая ставка является аномально низкой (именно такая ситуация невыгодна банку). Проанализируем, какие значения встречаются в листах дерева. Сначала посмотрим, какие свойства есть у объекта с деревом:

>>> dir(model)
['__abstractmethods__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_abc_cache', '_abc_negative_cache', '_abc_negative_cache_version', '_abc_registry', '_estimator_type', '_get_param_names', '_validate_X_predict', 'apply', 'class_weight', 'classes_', 'criterion', 'decision_path', 'feature_importances_', 'fit', 'get_params', 'max_depth', 'max_features', 'max_features_', 'max_leaf_nodes', 'min_impurity_decrease', 'min_impurity_split', 'min_samples_leaf', 'min_samples_split', 'min_weight_fraction_leaf', 'n_classes_', 'n_features_', 'n_outputs_', 'predict', 'presort', 'random_state', 'score', 'set_params', 'splitter', 'tree_']
>>> dir(model.tree_)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__pyx_vtable__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', 'apply', 'capacity', 'children_left', 'children_right', 'compute_feature_importances', 'decision_path', 'feature', 'impurity', 'max_depth', 'max_n_classes', 'n_classes', 'n_features', 'n_node_samples', 'n_outputs', 'node_count', 'predict', 'threshold', 'value', 'weighted_n_node_samples']

Теперь выведем 10 минимальных значений в листах:

>>> sorted(set(model.tree_.value.flatten()))[:10]
[0.10000000000000001, 1.0999999999999961, 1.0999999999999963, 1.0999999999999965, 1.0999999999999968, 1.099999999999997, 1.0999999999999972, 1.0999999999999974, 1.0999999999999976, 1.0999999999999979]

Видно, что обычно банк требует назад большую сумму, чем была выдана заёмщику изначально (коэффициент больше 1), но в одном случае сумма будет умножаться на коэффициент, меньший 1 (кредитная ставка будет отрицательной!). Очевидно, это и есть искомый бэкдор.

Как нужно заполнить анкету, чтобы воспользоваться уязвимостью? Найдём путь от корня до листа с бэкдором с помощью поиска в глубину:

path = []

def dfs(tree, index):
    if tree.children_left[index] == -1 and tree.children_right[index] == -1:
        if tree.value[index] < 1:
            return True
        return False
    
    if dfs(tree, tree.children_left[index]):
        path.append((index, '<'))
        return True
    if dfs(tree, tree.children_right[index]):
        path.append((index, '>'))
        return True
    return False

dfs(model.tree_, 0)
path.reverse()

Преобразуем номера признаков в их названия, которые встречались нам в словаре:

feature_names = ['credit_amount', 'dependents', 'duration', 'education', 'expense:alimony', 'expense:car_service', 'expense:credits', 'expense:housing', 'expense:insurance', 'expense:other', 'expense:taxes', 'has_overdue_debts', 'income:dividents', 'income:employee_wage', 'income:other', 'income:rent', 'income:state_wage', 'married', 'missed_deadlines', 'property:apartment', 'property:car', 'property:house', 'purpose:capital', 'purpose:car', 'purpose:education', 'purpose:other', 'purpose:real_estate', 'was_bankrupt']

constraints = []
for index, sign in path:
    feature = feature_names[model.tree_.feature[index]]
    threshold = model.tree_.threshold[index]
    
    constraints.append((feature, sign, threshold))
print('\n'.join(sorted(' '.join(map(str, item))
                       for item in constraints)))

Получаем набор условий:

credit_amount > 429002.5
dependents > 0.5
duration > 1.5
expense:alimony > 17050.5
expense:alimony > 46715.5
expense:credits < 46768.5
expense:insurance < 19356.0
expense:other < 13149.5
has_overdue_debts > 0.5
income:dividents < 82775.5
income:employee_wage > 48568.0
income:employee_wage > 65122.0
income:other < 197203.0
income:other > 112665.5
income:other > 65113.5
income:rent > 59898.5
income:state_wage < 58667.5
missed_deadlines > 0.5
property:apartment > 1639374.0
property:apartment > 3340466.5
property:car < 1247488.5
property:house < 1511438.0
was_bankrupt < 0.5

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