对我的文凭上的验证二维码进行逆向工程

2024 年 6 月 18 日欧洲东部时间 15:50,我走出了最后一门考试,终于在 13 年之后完成了学业。这也意味着我可以再次开始编程,因为我之前被迫辞去工作,专心学习。

三天后,我收到了一份 PDF,上面有我考试的成绩。然而,让我印象深刻的是右上角的二维码。这就是 PDF 的样子。(这不是真的。事实上,你去扫描二维码吧,我敢说你敢。)

关于代码功能的唯一线索是下面的一小段文字,其中引用了 基克拉泽斯检查,一款移动应用程序。为了满足我的好奇心,我下载了它并扫描了代码。然后我得到了我的个人信息和成绩的摘要。我猜这个代码是为了让大学或雇主能够验证你没有篡改 PDF 来提高你的成绩。他们提供了一个示例,展示了扫描二维码后的样子 应用商店

例子

现在我确信你们都在问自己一个问题: 我们能否对此进行逆向工程?更重要的是,我们能否破解它?

我的第一次幼稚尝试

这不是我第一次看到可以验证数据的二维码。 绿色通行证应用程序 在 Covid 期间随处可见的这种技术允许当局或活动工作人员扫描您的代码,以检查是否有最新的 Covid 测试或疫苗接种证明。

这些通常通过使用 base64 对数据进行编码并在末尾附加签名来工作。如果你是一名 Web 开发人员,其工作原理类似于 JSON Web 令牌。签名是使用 RSA 私钥加密的数据的哈希值。当用户扫描二维码时,签名会被解密为原始哈希值,然后将其与二维码中发送的数据的新哈希值进行比较。如果两个哈希值匹配,则可以确定内容没有被篡改。如果不匹配,您就知道不能信任这些数据。

由于我对法国当局的计算机安全知识至少有一点信任,所以我认为他们不仅会存储有关用户的数据,还会对其进行签名。

当我扫描代码以提取数据时,我发现的是一个简单的 base64 编码字符串(我知道它,因为它以 == 结尾),长度正好是 258 个字节。但我的兴奋并没有持续多久,因为解码的结果是随机字节,似乎不包含任何有用的数据。

揭露创建二维码所犯下的暴行

在让我的朋友也把他的 PDF 发给我后,我发现代码也是 258 字节长。对于任何对 RSA 加密有一点了解的人来说,这都不是好兆头。但当时我完全无知,以为我做错了什么——我想我可能选择了错误的字符集——经过一番摆弄后,它就能很好地解码。

但当我仍然留下难以辨认的乱码数据时,我知道我必须对应用程序本身进行逆向工程才能发现它是如何访问数据的。

但首先还有一件事要尝试:该应用程序是否访问了互联网来获取文凭?这将告诉我是否有希望找到一个简单而快速的解决方案。将手机置于飞行模式后,我仍然可以通过该应用程序访问数据。这是个好消息,因为它证实了数据和解密它的密钥都必须在应用程序本身中。但实际访问它们将是另一个挑战。

反汇编应用程序

我做的第一件事就是去 反编译器网站 我把应用程序的 APK 上传到了那里。凭借我对 Android 应用程序的一点了解,我直接进入了源文件夹,寻找有用的 Java 代码。

我找到了 Java 代码,但大部分都是乱码。稍微熟悉 Java 的人都知道你要找的是 MainActivity。但我在 sources/fr/edu/rennes/cylades/mobile/verifcertif/CycladesVerif/MainActivity.java 中发现的 – 是的,有很多文件夹 – 是一个空文件:

包 fr.edu.rennes.cyclades.mobile.verifcertif.CycladesVerif;导入 io.flutter.embedding.android.i;公共类 MainActivity 扩展了 i { }

经过一番谷歌搜索后,我意识到了一些事情,如果你花时间阅读导入包的名称,这应该是显而易见的:该应用程序正在使用 。在谷歌上搜索了一段时间(好吧,搜索了很多次)并浏览了 50 个 Reddit 帖子后,我知道要查找一个名为 libapp.so 的文件。它应该包含应用程序的(已编译)Dart 代码,而这些代码对于人类来说几乎是不可读的。

使用 Ctrl+FI 尝试剖析这种疯狂。我正在寻找一些具体的东西,即公钥或私钥以及 RSA 或其他类似加密方案的提及。我第一次搜索公共密钥时,在一大堆垃圾方法名称和指针引用之间找到了宝石 —-BEGIN RSA PUBLIC KEY—–。

这意味着我的怀疑基本得到证实。该应用程序使用 RSA 公钥解密扫描数据并提取文凭。但要进一步调查,我必须获得更易读的源代码。幸运的是,我的一位朋友最近在大学完成了计算机安全课程,并发表了一篇该领域的论文,他能够使用 指导 将 Dart 代码反汇编为汇编代码。

挖掘汇编代码

经过大量搜索,我终于在 asm/CycladesVerif/screens/scan.dart 中找到了一个似乎负责该应用程序主要功能的文件。它再次包含 —-BEGIN RSA PUBLIC KEY—– 字符串,还包含 _decodeWithRSAToken 等方法名称。

经过一两天的时间熟悉所提供的汇编代码的语法和不同指令后,我就能将这些步骤拼凑在一起来重现正在发生的事情。

  • 首先,应用程序调用函数 scanBarcode() 从二维码获取数据

  • 然后,它会比较前两位数字,以找出文档的版本。它可以是 01、02、03 或 04。这两个数字占 258 个字节的前两位。这意味着加密数据本身的长度为 256 字节。这正是使用 RSA 2048 位密钥加密某些内容的结果的长度。

  • 根据比较结果,它使用不同的方法和不同的公钥来解密。我的文凭以 02 开头,因此应用程序调用 _decodeRlnWithRSAToken2022()。

  • 此函数首先创建 RSA 令牌的 PEM 版本 – 这意味着它在密钥前面添加 —-BEGIN RSA PUBLIC KEY—– 并在密钥后面添加 —–END RSA PUBLIC KEY—– 后缀。然后它使用 base64 解码二维码,然后使用 Dart 包 pub.dev/packages/pointycastle 使用 RSA 公钥和 PKCS#1 填充对其进行解密。使用 Latin1 解码器处理生成的字节 – Latin1 是一种文本字符集,如 UTF-8。

  • 然后将解密和解码的文本传递给 _transformTextToReleveNotes() 并使用正则表达式进行解析,提取数据并将其显示给用户。

  • 在与 openssl 命令行工具搏斗并调用一些神秘选项后,我设法让它正确解密字节。解密结果如下所示:

    openssl rsautl -verify -inkey key.pem -pubin -in data.bin -raw -hexdump 0000 – 00 01 ff ff ff ff ff ff-ff ff ff ff ff ff ff ff ff ……………. 0010 – ff ff ff ff ff ff ff ff-ff ff ff ff ff ff ff ff ……………. 0020 – ff ff ff ff ff ff ff ff-ff ff ff ff ff ff ff ff ……………. 0030 – ff ff ff 00 42 61 63 63-61 6c 61 75 72 e9 61 74 ….Baccalaur.at 0040 – XX XX XX XX XX XX XX XX-XX XX XX XX XXXX XX 集体会议 0050 – XX XX XX XX XX XX XX-XX XX XX XX XX XX XX 2024|XXXXYYYYY| 0060 – XX XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX Niklas|XXXXXX|10 0070 – XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX .00|允许提及 0080 – XX XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX n Tr.s Bien avec 0090 – XX XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX les f.licitatio 00a0 – XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX XX ns du jury||Epre 00b0 – XX XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX uve 口头术语 00c0 – XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX 口头(大口头) 00d0 – XX XX XX XX XX XX XX-XX XX XX XX XX XX XX ~10.00#Math.mati 00e0 – XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX ques~10.00#Physi 00f0 – XX XX XX XX XX XX XX-XX XX XX XX XX XX XX XX que-Chimie~10.00

    从中我们可以使用 Latin1 字符集提取以下文本:

    2024年学士学位全体会议|XXXXXXX|Niklas|010101|10.00|在评审团的祝贺下以优异的成绩录取||期末口试(大口试)~10.00#数学~10.00#物理化学~10.00

    汇编代码显示,随后使用以下正则表达式进行解析,提取管道符 | 之间的字符。

    (.*)|(.*)|(.*)|([0-9]{2}[0-9]{2}[0-9]{2})|(.*)|(.*)|(.*)|(.*)

    最后,在 _transformTextToReleveNotes() 函数中,末尾的成绩 (…Mathematiques~10.00#Physique-Chimie~10.00) 由标签 # 分隔,然后用波浪符号 ~ 分隔,以便解析并显示给用户。

    如果你有兴趣扫描自己的文凭, src/load_diploma.py 是一个重新创建此应用程序功能的 Python 程序。

    那么这里的问题是什么?

    第一个问题是完全无视与 RSA 密钥使用相关的任何标准。使用私钥加密和使用公钥解密通常仅在签名/验证的上下文中进行。这与解密消息填充开头的 x00x01 一致。x01 表示 PKCS#1 填充中的块类型 1,这意味着执行的操作是签名。

    Python 的加密库直接拒绝使用公钥进行任何解密,唯一可用的方法是验证。这需要您提供签名和消息,但在这种情况下这是不可能的,因为签名不是消息的哈希值,而是消息本身。因此,我不得不求助于晦涩难懂的 openssl 命令,而在我的 Python 程序中,不得不在 Python 中重新实现 RSA 算法才能获得有用的结果。

    如果这还不够糟糕的话,他们还使用 PKCS#1 v1.5,由于填充 oracle 攻击,它被认为是不安全的,建议改用 OAEP 填充。

    那么我们能打破它吗?

    坏消息是,这可能是不可能的。但无论如何,我还是评估了几种方法,以测试它们的可行性。

    Bleichenbacher 填充 Oracle 攻击

    根据 owasp.org 可以使用填充预言攻击来解密 RSA PKCS#1 密钥加密的消息,正如 Bleichenbacher 在其 据我所知,如果您可以访问一个预言机,它会告诉您加密消息是否对应于具有有效填充的消息,那么恢复明文是可能的,因为这允许人们迭代地缩小加密消息的搜索间隔。查看一个很棒的演示 这里

    在这种情况下,我们需要的是这种攻击的逆向。然后我们可以伪造一条加密消息,当应用程序使用公钥解密时,它会显示我们想要的任何文凭。这种攻击是由 Daniel Bleichenbacher 在 2006 年提出的,详情见 这篇很棒的博客文章。但遗憾的是,这仅适用于指数为 e=3 的 RSA 密钥,而我们的应用程序并非如此,因为密钥的指数为 e=65537。

    这意味着我们不能用这种方法来伪造任何文凭。

    生成应用程序将显示的文凭

    为了让应用程序真正显示数据而不是退出,我们需要解密的文本与正则表达式匹配。最简单的有效文本是 ||||123456|||,因为它有 8 个 | 竖线分隔的部分和生日。竖线之间实际上不必有任何文本,因为 .* 匹配之间的任何字符 并且次数不限。

    这意味着我们可以计算出有多少条可能的消息与该正则表达式匹配。

    如果有 243 个字节的填充(x00x01xff… 238 次…xffx00),那么我们就有 $ 10^6 $ 条有效消息。这是因为每个字符串 ||||XXXXXX|||(其中 X 是任意数字)都匹配。因此,我们有 6 个字符,有 10 种可能性(0-9),这意味着总共有 $ 10^6 $ 条可能性。

    每少一个字节的填充,管道之间就会多一个随机文本字符 – 数字之间除外,因为它必须正好是 6 位数字。例如 Y||||123456||| 和 ||Y||123456|||(其中 Y 是任意字符)都是有效消息。由于应用程序使用 Latin1,因此有 189 个有效字符。

    要计算 $ n $ 个字符(即 $ 256 – 13 – n $ 个填充字节)的可能性数量,我们必须考虑有多少种方法可以将 $ n $ 个字符放入管道之间的空格中。这可以通过经典组合公式(称为星条定理)来解决。它计算将 $ n $ 个无法区分的球放入 $ k $ 个带标签的瓮中的方法数量。

    我们可以使用 python 来总结所有可能的组合:

    导入数学 def distinct_distributions(n, x): # 使用星号和条形的组合公式 return math.comb(n + x – 1, x – 1) total_possibilities = 0 for n in range(0, 256-13): total_possibilities += (189**n) * distinct_distributions(n, 7) total_possibilities *= 10**6

    但是我们想知道,通过反复检查所有可能的加密密文,我们是否有机会生成一条这样的消息。现有密文的总数为 $ 256^{256} $,因为它们必须正好是 256 个字节长。通过将 $ 256^{256} $ 除以与正则表达式匹配的消息数量,我们就知道在找到一条有效消息之前,平均需要尝试多少次。

    minimum_checks = 256**256 // total_possibilities print(f”在找到验证正则表达式的解密之前要执行多少次解密:{t}”) # 在找到验证正则表达式的解密之前要执行多少次解密:# 1319814855853530981336893039920992872421595910518

    遗憾的是,这个数字太多了。为了计算测试所有这些所需的时间,我使用了我编写的一个 C 程序的速度,该程序创建密文、解密并测试匹配度。它可以在 2.39 秒内执行 100k 次检查。

    speed = 100000 / 2.39 print(f”每秒检查次数 {v}”) # 每秒检查次数 # 41841.00418410041 time = (minimum_checks / speed) / 60 / 60 / 24 print(f”查找一个结果所需的天数:{time }”) # 查找一个结果所需的天数:# 3.650876742465208e+38

    不幸的是 $ 3 cdot 10^{38} $ 天是一个完全不切实际的时间跨度,所以我们将遗憾地不得不向法国投降……

    1720138792
    #对我的文凭上的验证二维码进行逆向工程
    2024-07-04 22:22:32

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    近期新闻​

    编辑精选​