「ACTF 2022」Broken QRCode Official Writeup

6 月 25~27 号我们 AAA 战队承办了一场 ACTF 比赛,我出了 Broken QRCode 这一道题目,这里是 writeup

English Writeup

First of all, the challenge has no attachment. Obviously the primary goal is to get the picture. According to the picture uuid recorded by the mirai QQ robot and the official API of QQ images, we can get the url of that picture:

1
https://gchat.qpic.cn/gchatpic_new/0-0-AA8F922E7A7C88CE82D73F614D8/0

Then the picture:

Then from the “Broken” in the title and the information “The QR code generator has bugs, the qrcode it generated can’t be scanned”, and after a simple analysis, you can see the four identifier bits at the beginning of the QR code, the size, and the padding bits in the middle. They are all exposed, so it can be speculated that there is no mask operation (you can also use the first hint “I broke this QR code by just missing a step” to be sure of this)

Load the picture into qrazybox. Then apply mask and scan it, you can get https://gist.github.com/TonyCrane/88dba1fb35297fef2b195495447a8a93 , which is a hex string of a zip pack. Unpack it, you can get 12 qr codes:

Scanning them, you will get:

1
2
3
4
5
6
7
8
9
10
11
12
We're no strangers to love
You know the rules and so do I
A full commitment's what I'm thinking of
You wouldn't get this from any other guy
I just wanna tell you how I'm feeling
Gotta make you understand
Never gonna give you up
Never gonna let you down
Never gonna run around and desert you
Never gonna make you cry
Never gonna say goodbye
Never gonna tell a lie and hurt you

After analysis, it can be found that there is an additional data at the end of the first picture:

1
2
3
410314c795f6b6e30775f5152436f64337d0ec187c229c4d4a44
# to binary
0100000100000011000101001100011110010101111101101011011011100011000001110111010111110101000101010010010000110110111101100100001100110111110100001110110000011000011111000010001010011100010011010100101001000100

It is not difficult to find out that this is a bit sequence (including identifier, size, data, padding, ecc) of a qr code content. The data it contains is the last part of flag: 1Ly_kn0w_QRCod3}

Then the first part of the flag will be obtained from these twelve qr codes

Through many tools, you can find errors in all of these qr codes. So you can guess that the ecc hide some changed bits. Then you can try to find out which bits are changed.

The most direct solution is to use the Reed-Solomon codes to find the position of the wrong bits, and then find its place in the qr code. But this is too complicated.

Since the content of each QR code is known, the version, error correction level, mask, and encoding method are all known. Then the qr code is uniquely determined. So as long as a correct QR code is generated, then we can find the changed place by diffing them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from qrcode import *
from PIL import Image, ImageOps, ImageChops

rick = [
"We're no strangers to love",
"You know the rules and so do I",
"A full commitment's what I'm thinking of",
"You wouldn't get this from any other guy",
"I just wanna tell you how I'm feeling",
"Gotta make you understand",
"Never gonna give you up",
"Never gonna let you down",
"Never gonna run around and desert you",
"Never gonna make you cry",
"Never gonna say goodbye",
"Never gonna tell a lie and hurt you",
]

for i, content in enumerate(rick):
img1 = Image.open(f"qrcodes/{i}.jpg").convert("RGB")
img2 = make(content, version=5, error_correction=ERROR_CORRECT_H).convert("RGB")
cropped_img1 = img1.crop(ImageOps.invert(img1).getbbox())
cropped_img2 = img2.crop(ImageOps.invert(img2).getbbox()).resize(cropped_img1.size)
ImageChops.difference(cropped_img1, cropped_img2).save(f"diff/{i}.jpg")

Now we get the first part of the flag. So the flag is ACTF{Y0u_Re41Ly_kn0w_QRCod3}

题目描述

翔鸽最近好像在筹备一个二维码的视频,在开发时不小心在群里泄露了题目,虽然及时撤回了,但还是从群里的 bot 日志翻到了以下信息:

1
2
3
4
5
2022-06-24 13:57:24 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 刚写完了二维码生成器,但是好像写出 bug 了,扫描不了,你们看看?
2022-06-24 13:57:50 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> [mirai:image:{AA8F922E-7A7C-886E-F54C-E82D73F614D8}.jpg]
2022-06-24 13:58:26 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 草
2022-06-24 13:58:58 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 才反应过来,这可不兴看啊,里面是要给 ACTF 出的题来着(
2022-06-24 13:59:06 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 撤了撤了(

解题过程

首先,题目只有这些文字内容,显然首要的目标是得到那个撤回了的图片,根据 mirai QQ 机器人记录下的图片 uuid 套入官方的接口就可以得到那张图片的 url:

1
https://gchat.qpic.cn/gchatpic_new/0/0--AA8F922E7A7C886EF54CE82D73F614D8/0

访问得到图片:

再由题目中的 Broken 以及信息 “写的二维码生成器出 bug 了,扫不了”,并且经过简单的分析可以看到二维码开头四个 bit、紧跟着的大小,以及中间的 padding 都是裸露在外的,所以可以推测是没有进行掩码操作(也可以通过第一个 hint “I broke this QR code by just missing a step“ 确定这一点)

将图片载入 qrazybox,打上一层掩码就可以直接扫描了,得到 https://gist.github.com/TonyCrane/88dba1fb35297fef2b195495447a8a93 ,里面是一个压缩包十六进制的字符串,存下来解压,得到十二个二维码:

扫描得到的结果是 Rickroll 歌词,而且均是完全的纯文本,没有内容上的把戏

1
2
3
4
5
6
7
8
9
10
11
12
We're no strangers to love
You know the rules and so do I
A full commitment's what I'm thinking of
You wouldn't get this from any other guy
I just wanna tell you how I'm feeling
Gotta make you understand
Never gonna give you up
Never gonna let you down
Never gonna run around and desert you
Never gonna make you cry
Never gonna say goodbye
Never gonna tell a lie and hurt you

经过分析可以发现第一张图片的结尾有附加数据:

1
2
3
410314c795f6b6e30775f5152436f64337d0ec187c229c4d4a44
# to binary
0100000100000011000101001100011110010101111101101011011011100011000001110111010111110101000101010010010000110110111101100100001100110111110100001110110000011000011111000010001010011100010011010100101001000100

再通过本题主要考点为二维码,所以不难推测这是一段二维码填充内容的比特序列(包括标识符、大小、序列、padding、纠错码),解析出来的结果是 1Ly_kn0w_QRCod3},即 flag 的后半段

那么前半段的 flag 就要从那十二个二维码中获得了

因为除了第一张图片以外,没有任何的图片隐写,所以问题应该就出在二维码自身上。通过很多工具都可以发现这些二维码中存在错误,扫描时都是靠纠错码来纠错得到的正确内容,所以便是通过纠错的特性隐藏了一些更改

最直接的解法是利用里的所罗门编码纠错,找到错误的比特,再反推到图上。但这样有些过于复杂

既然每个二维码的内容已知,版本号、纠错等级、掩码编号、编码方式都已知,那么最后的二维码是唯一确定的,所以只要生成一个正确的二维码再和题给的二维码逐个 diff 就可以找到被更改的点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from qrcode import *
from PIL import Image, ImageOps, ImageChops

rick = [
"We're no strangers to love",
"You know the rules and so do I",
"A full commitment's what I'm thinking of",
"You wouldn't get this from any other guy",
"I just wanna tell you how I'm feeling",
"Gotta make you understand",
"Never gonna give you up",
"Never gonna let you down",
"Never gonna run around and desert you",
"Never gonna make you cry",
"Never gonna say goodbye",
"Never gonna tell a lie and hurt you",
]

for i, content in enumerate(rick):
img1 = Image.open(f"qrcodes/{i}.jpg").convert("RGB")
img2 = make(content, version=5, error_correction=ERROR_CORRECT_H).convert("RGB")
cropped_img1 = img1.crop(ImageOps.invert(img1).getbbox())
cropped_img2 = img2.crop(ImageOps.invert(img2).getbbox()).resize(cropped_img1.size)
ImageChops.difference(cropped_img1, cropped_img2).save(f"diff/{i}.jpg")

即得到 flag 的前半部分,所以 flag: ACTF{Y0u_Re41Ly_kn0w_QRCod3}

后记

最后十二个二维码的修改是我在 qrazybox 里手动盲点的,导致最后一个二维码改错了没发现。比赛前一天写了 exp 跑了下才发现最后一个图点错了,于是直接重新改了一次,也懒得改其它的了。导致最后一个图的大小、位置等都和前面不一样。后来放了 hint 说了这个没有关系,但是貌似还是有很多师傅在最后一张图上盯了好久,给各位师傅道歉了呜呜呜 Orz

这题我感觉出的不是很难,没有刁钻地考二维码的东西(外面的一层也只是少了个掩码而已,没有什么裁剪啊,乱序啊,打马赛克啊一系列的骚操作),看到反馈有些师傅卡在了前半段,有些卡在了后半段,感觉都挺奇妙(特别是后半段,本来应该再构造一下的,比如穿插段数字模式编码啊什么的,这次完全用了字节模式,导致做一些移位就可以直接暴露出中间的内容,并不需要了解到这也和二维码有关

也可能是做的人少的原因,最后只有 L3H 一个队解出来了,蛮巧的是我也是在 L3HCTF 那场比赛的 cropped 题目之后才详细地学习了二维码,给 L3H 的师傅跪了 OTZ

总之感谢各位师傅参与这场比赛,希望这道题你们能喜欢 ( ̄▽ ̄)

「ACTF 2022」Broken QRCode Official Writeup

https://blog.tonycrane.cc/p/12a2afd2.html

作者

TonyCrane

发布于

2022-06-28

更新于

2022-09-03

许可协议