论如何又收一个新年解谜红包 – 2022 篇

注意:题目中出现的链接需要替换后才能访问 redpacket.kaaass.net=>redpacket.kaaass.net/archived/2022/。

各位好,我是KAAAsS。2022年的新年解谜红包也顺利的结束了~和往年一样,我也写了官方题解来解释解释今年的解谜红包。题目依旧在这里:https://redpacket.kaaass.net/

今年的解谜红包继续了去年的形式,采用了分关卡的形式。这种形式不仅能给解谜提供更明确的指引,而且给题目的准备也提供了极大的便利。另外值得一提的是,今年是新年解谜红包的第五个年头!因此在谜题设置上,不仅吸取了去年的经验给出了更多指引(希望区别于CTF),还在谜题中保留了一些延续性。从结果上而言,第一关最终的访问人数到达了173。虽然数字上并没有去年多(去年同期为283),但是在答题率、红包领取率上都更符合预期了(时隔2年,终于再次有人解出)。因此我感觉今年是比去年做的更好了。

#1 恭喜你获得新年红包

题目链接:https://redpacket.kaaass.net/problem/c9ad8ab4-3f0a-4297-8e0d-5f11bef341c5

本题目题面上是对去年#2题的延续。不过由于今年将它放在第一题,因此我也用心的装饰了下本题的门面。可以看到,今年的页面比起去年是有一个明显的提升的。截图无法展现动态效果,强烈建议打开网页深度体验动画。

初露端倪
颇能驾驭一种统一的设计语言

点击“开红包”后,将会显示一串红包码,但是文字部分被遮挡。

这是由于这一段文本的父元素设置了text-overflow: clip; overflow: hidden;,因此,直接复制或使用调试工具就能获得元素完整的内容。

e901175a62f2-4b4f-b98f-ebeb323dfe85且请在以第一个字符为一计算的第八个字符的右边加一个半角横线……

不同设备可能得到不同的结果

然而按照此指引并不能得到正确的答案。此处的提示有两个,一是不同的设备得到的结果并不一定相同,二是来自“活动规则”。

活动规则:
1. 用户点击“开红包”按钮即可获得红包码
2. 由于用户的显示设备宽度有限,因此页面的显示内容可能会经过一系列优化、简洁化处理
3. 平台保证且仅保证发出红包码,因设备问题导致红包码无法领取的,平台不承担任何责任

这说明宽度的限制并不止一处。通过对点击按钮时发出的请求进行观察,发现请求(stage1?w=66)包含了参数w。而且由于返回的结果末尾是省略号,显然后端也对返回长度进行的限制。查看页面关于请求相关的源代码,果然可以看到相关逻辑。该参数实际上是通过设备屏幕宽度(window.screen.width)计算得到的。

let deviceWidth = window.screen.width;
let scaleRate = Math.log(Math.max(deviceWidth - 500, 1.0)) / Math.log(1100);
let w = Math.ceil(scaleRate * 36 + 30);

fetch(`./api/stage1?w=${w}`)

因此,尝试使用更大的w发出请求,发现w其实就是返回内容的长度。在120左右就能得到足够解题用的信息了:

e901175a62f2-4b4f-b98f-ebeb323dfe85且请在以第一个字符为一计算的第八个字符的右边加一个半角横线然后所有字母向右旋转(字母挖空之后内容移动)两格比如a-bcd就是c-dab然后就行了我超凑满一百字嗯啊啊啊……

根据题目说明,实现一个向右旋转函数就可以得到结果了。

def rotate_right_alpha(s, n):
    "右旋的一种参考实现"
    alphas = list(filter(str.isalpha, s))
    alphas = alphas[-n:] + alphas[:-n]
    p = -1
    return ''.join(alphas[p:=p+1] if ch.isalpha() else ch for ch in s)

One more thing…

  • 本关通过数:42/173。红包设置了30个,差不多符合我的预期吧。因为设置了多个引导,因此解题率也比去年高了很多,几近翻倍
  • 虽然已经大不相同了,但其实本题是在玩某互联网企业2021年于法庭上产生的梗。因此原始设计其实也是进度的精度被隐藏,要完成红包提现操作。但由于担心解题过程较为繁琐,因此最后简化为红包码
  • 本题还存在另外一种做法,就是购买一台横向分辨率大于8000000px的设备。因此对于不存在设备问题的各位,本题可以直接跳步。“你 KAAAsS 哥什么时候骗过你”。当然,如果你碰巧手头没有专业设备,也可以使用调试工具模拟
  • 事实上,本题最大的难度很可能在于“右旋函数”的实现。有不止一位朋友和我反应无法理解“右旋”的准确含义,还有不止一位朋友根据-将UUID分区后各自旋转
  • 本题的炮仗来自于“摆烂系列”
  • 实际上,本题的w也存在最大值。在解题过程中,共有4人成功发现

#2 林纳斯认为 Linux 吸入

题目链接:https://redpacket.kaaass.net/problem/f901175e-62e2-4a4f-b98f-bfeb323ebd85

本关NETA自近期对Github上Linux内核仓库的一次行为艺术,原始commit骗到了不少人。实际上,这是利用Github的机制,不同fork其实是原始仓库的分支。因此用commit hash也可以从原仓库获得这个commit(比如git fetch origin [hash])。不过题目提到了红包其实在下一个提交里,因此本题要求找到原Fork的仓库。

一个很直观的想法就是进行搜索。虽然Github搜索默认不展示Fork,但提供了fork:true的选项。可惜,这并不适用于本题的情况。因为参考Github的文档,只有Star数大于父仓库的Fork才会开启代码搜索功能。那这个父仓库是最高的还是直接父仓库呢?很遗憾,经我实验,确实是最高的父仓库。也就是说,Fork的Star数要大于torvalds/linux。这对出题者来说颇具挑战,因此搜索肯定是吹了。

另一种办法就是一一穷举Fork了。因为Github的网页端只列出了100个Forks(我也确保了答案没有出现),所以需要通过Github的API来获取。那怎么判断呢?可以通过Github Commit页面的提示“This commit does not belong to any branch on this repository”所属的接口/{name}/linux/branch_commits/{hash}来判断。这也是大多数人解出此题的方法。

但如果只是这样,面对更早的Fork要怎么办呢?Linux内核有四万多个Fork,一个个爬取实在是没有效率。而且如果真的只有这种解法,那解谜红包也太没意思了。实际上,我们还能通过一些残留的信息,来缩小Fork的时间范围

不如我们就以前些天的原Commit为例。由于Commit是携带了父Commit的信息的,因此发生提交前的历史是可追溯的,由此可以根据它与主分支共享历史的最后时间,推测出Fork的最早时间(不过,要确保Linus不会Hack自己的仓库)。由于它的最近一次提交就是Merge tag ‘for-linus’…,因此Fork不会早于2022-01-25T06:02:46Z

有了这个信息后,我们转而查看下一次Linux本人对仓库的Push,来确定Fork发生的最晚时间。因为如果Fork晚于Linus的下一次Push,那master分支被更新,Fork就会包含之后的Commit了。而由于Push包含两个时间点之间的Commit,因此master分支的下一个Commit并不是真正的最晚时间。这也可以通过Github的API来确定,是2022-01-25T16:31:34Z

有了这两个时间,且Github的Fork API可以按创建时间排序,我们就可以快速的找到两个时间之间创建的Fork,只有14个,范围已经大大缩小了。不过,我们还能进一步缩小范围。由于这个Commit本身的时间是2022-01-25T16:31:19Z,因此Push的时间应该晚于它。再加上这个条件我们就可以发现,只剩下一个在2022-01-25T16:31:32Z进行Push的仓库了(手速挺快)。而这就是这次行为艺术的“幕后黑手”:nytpu/linux

但话说回来,其实Linux总共也只有四万多个Fork,全遍历一遍也用不了太久,好像也用不着这种方法。其实这个方法的真正应用也确实不是这样。在解谜过程中,其实有不少朋友问这个仓库是否可见。由于Github不允许Fork为私有,因此唯一的可能就是仓库被删除了。那删除了的Fork真的就没办法追溯了吗?当然有啦,答案就是通过API获得仓库的Push事件。即使是删除了仓库,但Fork事件也依旧不会被删除。而由于仓库事件众多,因此先定时间范围然后二分查找得到区间内的PushEvent就很有必要了。那有同学可能要问了,即使是能找到用户,但仓库都删了怎么看下一个Commit?答案是,通过用户事件API就可以得到用户Push的Commit Hash了,而同样这个Commit可以在父仓库中查看。这其实也是本题最完善的形态,但是这个难度如果放在#2也就太不讲理了。

不过,这个方法也并非万无一失。首先是最早时间,可以通过提前Fork再更新来把仓库创建时间错开。然后最晚时间也不一定,可以把近几次的Commit删掉,直到不是这次Push就可以了。同样Push时间的推测也不一定,因为Commit时间可以乱改。所以还是给Git配置个GPG来的保险啦。

One more thing…

  • 本关通过数:7/42。红包设置了10个,比预期要稍微少一点
  • 题目描述中的Ti之家纯属虚构,如有雷同,不胜荣幸
  • 题目中的Reference就是原理的讲解,如果没发现,可以将页面滚动到最底部然后去看看
  • 其实本关原定是最后一关的,不过最后还是调整到了#2的位置。最主要的原因是我对这题的骚话比较满意,实在想搬出来展示。但其实这题在#2的位置是有点难的,而且我预想中最可能的解出方法(暴力尝试)也需要写脚本,我其实不太愿意#2就麻烦人写脚本的……
  • 虽然位置调整,但是红包个数我却忘了调。所以一开始本题是发了15个红包的,直到解谜开始一段时间后才发现
  • 题目仓库的所有者账户sora-cyann与穹妹头像不仅是出于我的个人爱好,其实还是因为红包平台规则页面的Banner就是一张altSora cyann的穹妹,而且是同一张图。不过其实本题一开始打算的是用另一个账号,测试Fork搜索也用的是它。不过后来想想,还是保留了这个提示,让移动端(我试过FastHub)也能完成这个题目。感兴趣的话,可以按照文章中的提示找出这个账户

#3 三句话让 AES 大厦轰然倒塌

题目链接:https://redpacket.kaaass.net/problem/1a138448-888b-4ce8-8ebf-0460fe577e67

本题的题意还是比较明显的,题目就指出了AES。在代码中寻找AES,发现出现在加密部分:

def encrypt_image(self, image_idx):
    # 读入图片
    path = f'{image_path}/{image_idx}.bmp'
    with open(path, 'rb') as f:
        data = f.read()
    # 随机生成 Key
    key = bytes(rand.choices(list(range(256)), k=16))
    # 加密
    cipher = AES.new(key, AES.MODE_ECB)
    enc = cipher.encrypt(padding(data))
    # 压缩后 Base64 编码
    return base64.b64encode(zlib.compress(enc)).decode('utf-8')

程序的大概功能就是加密一张图片返回,然后生成若干个备选项以供选择。程序逻辑非常简单,也没有什么问题,随机数也是用os.urandom初始化的。因此,重点还是AES的运用,这里选择了ECB模式。在Google上搜索“ecb image”:

而且题目的图片也正好全是BMP,所以解题方法就很简单了——直接把随便一个图的BMP头(前54字节)替换掉加密结果就可以了。肉眼对比一下,其实两个图的关系还是比较明显的(毕竟图是挑过的)。

原图
加密后恢复的图,叠加原图

最终完成所有关卡并留名的Dalao们如下,TQL!

  • TechCiel
  • Yesterday17
  • Chaos
  • Ressed
  • KevinAxel

One more thing…

  • 本关通过数:5/7。红包设置了5个,正好领完~有趣的是,本题的红包领取正好是前5位解出#2的dalao,连顺序都相近
  • 本关是原定的#2,也和去年的#2在“图像提供信息”的解法做一个对应。不过由于懒得也没空写网页前端,最后就调整了题目形式(本来是想写福然后选自己写的)以适应nc,也因此放到了#3。
  • 然而,因为变更过于仓促,新的题目形式其实允许了其他解法。这里的问题就是,我提供了所有图的源文件。这直接导致解题完全没有必要根据图像提供的结构信息,找数据上的局部结构信息就可以了(因为ECB对同样块输出同样结果,因此只要据此寻找相等块,然后就能提取图片的特征)。这其实并非我的本意,因为我觉得这样太CTF了,是需要刻意避免的。当时应该提供图片的JPG版本或者干脆进行一些模糊处理的
  • 令我开心的是,至少一位dalao是按预期解出的,辛苦他们了
  • @KevinAxel的解法很有意思,是根据压缩后的大小进行判断的。虽然本质上也是一种考虑局部相对信息的方式,但也很有趣

一些有趣的事情

  • 今年红包采用均分制除了有往年过于诡异的领取情况(参见第一年去年)和@Yesterday17的红包的影响外,其实还和原定计划中的#3有关系。该题是关于发红包的博弈论相关题目,而在研究红包金额分配过程中,我意识到拼手气红包最高金额获得者极易获得2倍于均分金额的红包。这和我发红包的本意相悖,因此选择采用了均分。不过这个题目最终因为一些匹配上的实现等问题而没有发出
  • 今年的红包平台增加了“解谜进度”页面。虽然没有在页面中说明,但是解谜过程中共有62位同学一同见证解谜过程
  • 虽然感知不强,但其实今年的红包平台前端增加了许多易用性改善,支持了移动端设备

后记:新年解谜红包到底是什么?

总体而言,今年的解谜红包我个人是很满意的(虽然也出了锅,不过倒也无伤大雅)。但是,后来有朋友和我交流说本次解谜红包太简单了、有点水。我开始其实是不以为然的,因为毕竟这是红包嘛,也不是打CTF比赛。但深思之后,我确实意识到有必要弄清楚解谜红包的意义。于是经过沉思后,我希望给出我自己的答案(因为这毕竟是我搞的嘛,233)。

解谜红包是怎么来的呢?我在第一年红包的题解中其实有致谢,最早是因为看到@SuperFashi佬的博客。后来朋友给我发了@CancerGary佬的红包,我做完之后发现确实很有意思。于是次日(大年初一)回家的路上用备忘录写下了那年的红包。当时参考了@SuperFashi佬的红包,设置了Stage形式的5关(甚至连解法也搬来了)。实现也不太难,全用PHP,第二天就发出来了。因为当时和群友交流的过程中产生了不少有趣的事情,甚至还追加了隐藏关(所有隐藏关都是发后追加的),而且大多都是私聊交流的,所以我就在题解中追加了很多“有趣的事”,希望没参加的朋友看题解也能得到乐趣。那年正好是高三寒假,初五赶完题解后就差不多收拾收拾润了。

第一年的红包包含了很多豆知识,也就是我说的“实用”。再加上趣闻、隐藏,所以大概我觉得光看题解也不会显得太乏味。于是,第二年的红包也是按着同样的思路设计的,但是塞爆。结果就是那年Stage1的通过都很少,搞得紧急在Stage2脸上追加了红包,续红包也是从那年来的。不过那年还是有dalao收了,所以我可能还没有意识到其中的问题。

第三年发生了很多事,其中最有意思的应该属我终于弄懂第一年群友说的“大过年还让我做CTF”是什么意思了。因为红包,我阴差阳错地离开ACM,成为了一名MISC手。也许对我而言,CTF比赛就是一个个红包(而且真能开出不少钱)。于是那年的题不仅精心巨大塞爆,思路还参考了做过的题,结果也就理所当然的寄了——红包无人解出、Stage3只有一个请求。这给了我挺大的打击。终于在那时,我意识到了“红包的意义在于被收”。有人回老家、有人工作一年难得休息,每个人都有自己的事情要做。搞一个打不开的宝箱不会让任何人开心,也决不显得自己聪明。

这一切都落地于去年红包发生的大变化:增加了若干红包、明确标出了关卡以便解题的导向、去掉了隐藏。虽然还是没人领到最后,但至少比去年好了不少。不过那年的题我也不尽满意,大多题目指引不太明显,甚至故弄玄虚。后来也有朋友跟我提过这事。于是到了今年,所有的题目都尝试增加了引导或标志。为了让题解也有趣,有些题(#2)甚至是先写题解再出的。

一路下来,解谜红包似乎一直都和“分享”离不开关系——始于感觉有趣的分享、题目希望分享一些“技术”、题解希望分享趣事,但乐趣却不总是在场。我很喜欢@SeraphJACK去年的红包,但反思今年的题目,好像确实少了点柳暗花明的乐趣(解法都比较“直”)。我是否在解谜红包上托付了太多呢?

一不留神竟然已经写了这么多,劳烦各位看了那么多无聊的话。给大家拜个晚年,祝各位新年快乐!明年见!至于明年的红包会怎样,会不会继续这个形式?会不会有隐藏?我也不知道。但如果可以的话,我希望它至少有趣。

分享到

KAAAsS

喜欢二次元的程序员,喜欢发发教程,或者偶尔开坑。(←然而并不打算填)

相关日志

  1. 没有图片
  2. 没有图片
  3. 没有图片
  4. 没有图片

评论

  1. Ressed 2022.02.12 4:17下午

    才发现已经五年了 感动

    • KAAAsS 2022.02.14 11:41上午

      感谢老熟人捧场~

  2. Makise Von 2022.02.17 4:00下午

    居然没有 ./api/stage1?w=114515

  3. Makise Von 2022.02.17 4:05下午

    今年终于能看懂所有题解了 qaq

在此评论中不能使用 HTML 标签。