Skip to content

Commit

Permalink
Prototype Pollution (Server Side) + Email Injection (#307)
Browse files Browse the repository at this point in the history
A client side endpoint that is vulnerable to prototype pollution

---------

Co-authored-by: Tamir Gershberg <[email protected]>
  • Loading branch information
MaximAshin and tamirGer authored Apr 6, 2024
1 parent f6fe49e commit 7b546bc
Show file tree
Hide file tree
Showing 19 changed files with 668 additions and 100 deletions.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The full API documentation is available via swagger or GraphQL:
npm ci && npm run build

# build client
npm ci --prefix public && npm run build --prefix public
npm ci --prefix client && npm run build --prefix client

#build and start dockers with Postgres DB, nginx and server
docker-compose --file=docker-compose.local.yml up -d
Expand Down Expand Up @@ -177,4 +177,19 @@ Additionally, the endpoint PUT /api/users/one/{email}/photo accepts SVG images,
3. The endpoint GET `/api/partners/query` is a raw XPATH injection endpoint. You can put whatever you like there. It is not referenced in the frontend, but it is an exposed API endpoint.
4. Note: All endpoints are vulnerable to error based payloads.

* **Prototype Pollution** - The `/marketplace` endpoint is vulnerable to prototype pollution using the following methods:
1. The EP GET `/marketplace?__proto__[Test]=Test` represents the client side vulnerabillity, by parsing the URI (for portfolio filtering) and converting
it's parmeters into an object. This means that a requests like `/marketplace?__proto__[TestKey]=TestValue` will lead to a creation of `Object.TestKey`.
One can test if an attack was successful by viewing the new property created in the console.
This EP also supports prototyp pollution based DOM XSS using a payload such as `__proto__[prototypePollutionDomXss]=data:,alert(1);`.
The "legitimate" code tries to use the `prototypePollutionDomXss` parameter as a source for a script tag, so if the exploit is not used via this key it won't work.
2. The EP GET `/api/email/sendSupportEmail` represents the server side vulnerabillity, by having a rookie URI parsing mistake (similiar to the client side).
This means that a request such as `/api/email/sendSupportEmail?name=Bob%20Dylan&__proto__[status]=222&to=username%40email.com&subject=Help%20Request&content=Help%20me..`
will lead to a creation of `uriParams.status`, which is a parameter used in the final JSON response.

* **Date Manipulation** - The `/api/products?date_from={df}&date_to={dt}` endpoint fetches all products that were created between the selected dates. There is no limit on the range of dates and when a user tries to query a range larger than 2 years querying takes a significant amount of time. This EP is used by the frontend in the `/marketplace` page.

* **Email Injection** - The `/api/email/sendSupportEmail` is vulnerable to email injection by supplying tempred recipients.
To exploit the EP you can dispatch a request as such `/api/email/sendSupportEmail?name=Bob&to=username%40email.com%0aCc:%[email protected]&subject=Help%20Request&content=I%20would%20like%20to%20request%20help%20regarding`.
This will lead to the sending of a mail to both `[email protected]` and `[email protected]` (as the Cc).
Note: This EP is also vulnerable to `Server side prototype pollution`, as mentioned in this README.
7 changes: 7 additions & 0 deletions client/public/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,13 @@ section {
padding-top: 30px;
}

.marketplace-gem-filter-input {
margin-left: 20%;
margin-right: 20%;
max-width: -webkit-fill-available;
margin-top: 15px
}

.section-title h2 {
font-size: 32px;
font-weight: bold;
Expand Down
3 changes: 2 additions & 1 deletion client/src/api/ApiUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export enum ApiUrl {
Spawn = '/api/spawn',
File = '/api/file',
NestedJson = '/api/nestedJson',
Partners = '/api/partners'
Partners = '/api/partners',
Email = '/api/email'
}
12 changes: 12 additions & 0 deletions client/src/api/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,15 @@ export function searchPartners(keyword: string): Promise<any> {
method: 'get'
});
}

export function sendSupportEmailRequest(
name: string,
to: string,
subject: string,
content: string
): Promise<any> {
return makeApiRequest({
url: `${ApiUrl.Email}/sendSupportEmail?name=${name}&to=${to}&subject=${subject}&content=${content}`,
method: 'get'
});
}
25 changes: 24 additions & 1 deletion client/src/pages/main/Contact.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { sendSupportEmailRequest } from 'src/api/httpClient';

export const Contact = (props: { mapTitle: string | null }) => {
useEffect(() => {
Expand All @@ -10,6 +11,21 @@ export const Contact = (props: { mapTitle: string | null }) => {
}
}, []);

const sendSupportRequestEmailAction = () => {
const formName = document.getElementById('name')?.value;
const formEmail = document.getElementById('email')?.value;
const formSubject = document.getElementById('subject')?.value;
const formMessage = document.getElementById('message')?.value || '';

if (!(formName && formEmail && formSubject)) {
return alert(
'The email form is incomplete - Please fill out all required sections.'
);
}

sendSupportEmailRequest(formName, formEmail, formSubject, formMessage);
};

return (
<section id="contact" className="contact section-bg">
<div className="container" data-aos="fade-up">
Expand Down Expand Up @@ -105,6 +121,7 @@ export const Contact = (props: { mapTitle: string | null }) => {
<textarea
className="form-control"
name="message"
id="message"
rows={5}
data-rule="required"
data-msg="Please write something for us"
Expand All @@ -120,7 +137,13 @@ export const Contact = (props: { mapTitle: string | null }) => {
</div>
</div>
<div className="text-center">
<button type="submit">Send Message</button>
<button
id="send-email-button"
onClick={() => sendSupportRequestEmailAction()}
type="submit"
>
Send Message
</button>
</div>
</form>
</div>
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/main/Header/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const menu: Array<MenuItem> = [
{ name: 'Home', path: '/?maptitle=map', newTab: false },
{
name: 'Marketplace',
path: '/marketplace?videosrc=https://www.youtube-nocookie.com/embed/MPYlxeG-8_w?controls=0',
path: '/marketplace?portfolio_query_filter=&videosrc=https://www.youtube-nocookie.com/embed/MPYlxeG-8_w?controls=0',
newTab: false
},
{ name: 'Edit user data', path: RoutePath.Userprofile, newTab: false },
Expand Down
65 changes: 62 additions & 3 deletions client/src/pages/marketplace/Marketplace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Testimonials from './Testimonials/Testimonials';
import ProductView from './ProductView';
import DateRangePicker from './DatePicker';
import Partners from './Partners/Partners';
import splitUriIntoParamsPPVulnerable from '../../utils/url';

interface Props {
preview: boolean;
Expand Down Expand Up @@ -71,6 +72,47 @@ export const Marketplace: FC<Props> = (props: Props) => {
}
}, []);

const searchStringInProductNameOrDescription = (
searchString: string,
product: Product
) => {
searchString = searchString.toLowerCase();
return !searchString ||
product.name.toLowerCase().includes(searchString) ||
product.description.toLowerCase().includes(searchString)
? product
: null;
};

// Note: This function is vulnerable to Prototype Pollution
const currentUriParams: Record<string, any> = splitUriIntoParamsPPVulnerable(
document.location.search
);

const [portfolioQueryFilter, setPortfolioQueryFilter] = useState(
currentUriParams &&
currentUriParams.hasOwnProperty('portfolio_query_filter')
? currentUriParams['portfolio_query_filter']
: ''
);

/*
If the 'prototypePollutionDomXss' key is present (which can stem from prototype pollution or just a regular URI parameter)
then a <script> element is created with the key's cooresponding value as a source
*/
let scriptElementProrotypePollutionDomXSS;
if (currentUriParams.prototypePollutionDomXss) {
scriptElementProrotypePollutionDomXSS = document.createElement('script');

scriptElementProrotypePollutionDomXSS.id =
'prototype-pollution-dom-xss-script';
scriptElementProrotypePollutionDomXSS.src =
currentUriParams.prototypePollutionDomXss;
scriptElementProrotypePollutionDomXSS.async = true;

document.body.appendChild(scriptElementProrotypePollutionDomXSS);
}

const handleDateChange = (dateFrom: Date, DateTo: Date) => {
getProducts(dateFrom, DateTo).then((data) => setProducts(data));
};
Expand All @@ -84,6 +126,16 @@ export const Marketplace: FC<Props> = (props: Props) => {
<div className="section-title marketplaceTitle">
<h2>Marketplace</h2>
</div>
<div className="section-title qmarketplaceTitle">
<h3>Gem Filter</h3>
<input
className="form-control marketplace-gem-filter-input"
id="portfolio-query-filter"
placeholder="Filter for gems easily"
defaultValue={portfolioQueryFilter || ''}
onChange={(e) => setPortfolioQueryFilter(e ? e.target.value : '')}
/>
</div>
{props.preview || (
<div className="row">
<DateRangePicker onDatesChange={handleDateChange} />
Expand All @@ -101,9 +153,16 @@ export const Marketplace: FC<Props> = (props: Props) => {
)}
<div className="row portfolio-container">
{products &&
products.map((product, i) => (
<ProductView product={product} key={i} />
))}
products.map((product, i) =>
searchStringInProductNameOrDescription(
portfolioQueryFilter,
product
) ? (
<ProductView product={product} key={i} />
) : (
<></>
)
)}
</div>
</div>
{props.preview && (
Expand Down
74 changes: 74 additions & 0 deletions client/src/utils/url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Taken from PortSwigger's prototype pollution labs
// VULNERABLE TO PROTOTYPE POLLUTION!
var splitUriIntoParamsPPVulnerable = (params, coerce) => {
if (params.charAt(0) === '?') {
params = params.substring(1);
}

var obj = {},
coerce_types = { true: !0, false: !1, null: null };

if (!params) {
return obj;
}

params
.replace(/\+/g, ' ')
.split('&')
.forEach(function (v) {
var param = v.split('='),
key = decodeURIComponent(param[0]),
val,
cur = obj,
i = 0,
keys = key.split(']['),
keys_last = keys.length - 1;

if (/\[/.test(keys[0]) && /\]$/.test(keys[keys_last])) {
keys[keys_last] = keys[keys_last].replace(/\]$/, '');
keys = keys.shift().split('[').concat(keys);
keys_last = keys.length - 1;
} else {
keys_last = 0;
}

if (param.length === 2) {
val = decodeURIComponent(param[1]);

if (coerce) {
val =
val && !isNaN(val) && +val + '' === val
? +val // number
: val === 'undefined'
? undefined // undefined
: coerce_types[val] !== undefined
? coerce_types[val] // true, false, null
: val; // string
}

if (keys_last) {
for (; i <= keys_last; i++) {
key = keys[i] === '' ? cur.length : keys[i];
cur = cur[key] =
i < keys_last
? cur[key] || (keys[i + 1] && isNaN(keys[i + 1]) ? {} : [])
: val;
}
} else {
if (Object.prototype.toString.call(obj[key]) === '[object Array]') {
obj[key].push(val);
} else if ({}.hasOwnProperty.call(obj, key)) {
obj[key] = [obj[key], val];
} else {
obj[key] = val;
}
}
} else if (key) {
obj[key] = coerce ? undefined : '';
}
});

return obj;
};

export default splitUriIntoParamsPPVulnerable;
12 changes: 12 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,15 @@ services:
max-size: '10m'
depends_on:
- db

mailcatcher:
image: sj26/mailcatcher
container_name: mailcatcher
restart: always
ports:
- '1080:1080'
- '1025:1025'
logging:
options:
max-file: '5'
max-size: '10m'
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ services:
retries: 3
depends_on:
- keycloak-db

mailcatcher:
image: sj26/mailcatcher
container_name: mailcatcher
restart: always
ports:
- '1080:1080'
- '1025:1025'
logging:
options:
max-file: '5'
max-size: '10m'

volumes:
letsencrypt:
Loading

0 comments on commit 7b546bc

Please sign in to comment.