Beyond CRTO: Skillable

TL;DR

While working on the RTO course tooling, I found Skillable’s SCORM lab launch path trusted a userId the browser supplies for allocation, while validating only the SCORM token. Changing that value spun up fresh lab instances and, with a colleague’s permission, used up their allocation. This lets any enrolled user bypass the rate limit and deny service to others. The issue was reported to Skillable on 11 May; Skillable responded that it was an inherent SCORM limitation, not an implementation flaw, and sent customers a private migration advisory. No fix is planned for the SCORM path and remediation is migration to an API or LTI 1.3 launch integration. MITRE reserved CVE-2026-56877.

Discovery

Post 1 in this series closed on the note that I had reported two security issues in the lab platform. Post 2 covered the first, a symlink traversal and TOCTOU in pwnlift that MITRE eventually assigned CVE-2026-56815. This is the second bug found in the RTO course stack during userscript work, and it has nothing to do with pwnlift.

The labs themselves run on Skillable (formerly Learn on Demand Systems, labondemand.com), a hosted service for provisioning labs, connected to a learning management system over SCORM. Zero Point Security uses it for both practice labs and the exam on Red Team Ops. Other Skillable customers and partners include Microsoft, IBM, CompTIA, and Pearson VUE.

If you’re as old as I am, there is a decent chance SCORM has passed you by. It’s an e-learning standard from the early 2000s, mostly a client-side JavaScript API that lets course content talk to a learning management system. Fine for progress tracking, but it was never meant to carry verified identity to an external service.

I swear I wasn’t actively looking for this. While building my Tampermonkey userscript, I had an intercepting proxy running (well, two, actually) so I could poke at the LearnWorlds endpoint that unlocks attachments, which I was using to fetch lab markdown for indexing. At some point I hit a lab I had already started that day, where the response said: “Sorry, you have taken this lab the maximum number of 1 times.” This caught my attention as the wording suggested some sort of per-user limit, meaning the user identity had to be arriving from somewhere the server trusted. At that point I was not testing Skillable, and was barely trying to make the userscript work.

My proxy configs had several match-and-replace rules left over from the userscript work, written…loosely, with the kind of overly broad regex that you’re later ashamed of catches more than it was meant to. One of them mutated the userId on a subsequent launch request and the response came back 200 with a fresh LabInstanceId. I had bypassed the limit I had just hit, and done it by accident, through a regex written for a completely unrelated job.

That could have been a tooling artefact, so I decided to reproduce it by setting a rule to change the last character of my own userId to a value that was not even valid hex, then launched the same lab again, and got another 200 with another LabInstanceId. The SCORM token and the userId were not being cross-validated. The server authenticated the token, then made its per-user allocation decision against a parameter the client fully controlled.

That is the whole bug in a nutshell. The next step was to reduce that observation into the actual request flow and find where the trust boundary failed.

Bug walkthrough

The userId that Skillable trusts for allocation is put on the wire by the browser, and it gets there through a stack of nested iframes. Loading a lab unit on the Zero Point Security platform builds this chain:

LearnWorlds (zeropointsecurity.co.uk)
  -> course player
    -> SCORM iframe
      -> SCORM player
        -> Storyline iframe
          -> fetch(scorm.skillable.com/scorm/launch?token=...&userId=...)
            -> Skillable provisions the lab VMs

Every layer runs client-side and by the time the launch request reaches scorm.skillable.com, the token, the userId, and the rest of the query string have all passed through JavaScript the student controls. The browser is the transport for the whole flow, identity included.

Skillable’s side of the exchange runs five steps:

  1. The student opens a lab unit in the LearnWorlds course player.
  2. LearnWorlds loads the SCORM token and builds the launch URL, dropping the student’s userId in as a query parameter.
  3. The browser requests that URL from scorm.skillable.com.
  4. Skillable validates the SCORM token as an enrolment check, then evaluates the per-user launch limit against the supplied userId.
  5. If the limit has not been hit, a lab instance is provisioned and a one-time access URL is returned.

The bug lives in step 4, where the token answers one question (is this session enrolled) and the userId answers another (whose allocation does this launch count against). However, only the token is a value the server can trust, and the two are never checked against each other. Skillable authenticates the token but bases its allocation decision on a value the client typed, which is authorisation bypass through a user-controlled key (CWE-639) - an IDOR in the launch path.

The token itself is a double base64-encoded binary blob, around 400 characters encoded and 224 bytes raw, and it is static (likely baked into the SCORM package rather than created per session). That leaves userId as the only per-user value Skillable acts on, and LearnWorlds’ own completion state, tracked separately on the LMS, is cosmetic from Skillable’s side of the boundary.

That parameter accepts arbitrary strings with no format validation. Both 683175c1ac36b0299c24b31g (valid length, non-hex final character) and 000000000000000000000001 (a completely fabricated structure) were accepted and provisioned lab instances. The same SCORM token launches labs under different userId values, confirming the token is not bound to a specific user identity, and each launch under a distinct userId creates a separate LabInstanceId and provisions real cloud VMs.

The proof of concept is just three steps: (1) Launch a lab normally until the endpoint returns the per-user error (“Sorry, you have taken this lab the maximum number of 1 times”), (2) intercept the launch request and alter a single character of the userId, (3) launch again with the same token, then observe that the modified userId comes back 200 with a fresh LabInstanceId.

Proxy capture of the modified userId launch returning HTTP 200 with a new LabInstanceId
Proxy capture of the modified userId launch returning HTTP 200 with a new LabInstanceId

Proxy capture of the launch request with the modified userId (token redacted); the server returns 200 OK with a fresh LabInstanceId, confirming the bypass.

All of this was confirmed against my own account; turning it into denial of service against another student needed someone else’s userId.

Cross-user impact

A former colleague I talk to most days was also working through RTO, so I gave him the short version and asked if he’d help. He agreed, set a unique display name so his userId could be resolved through the mention lookup, and on the morning of the test confirmed he hadn’t launched any labs that day (needed a clean allocation to test against).

On 3 May, I launched a random lab under his userId, then he tried to start that same lab himself at 10:15 AM. The request failed with “ERROR: There was an error when attempting the request to launch the lab…”, while his other labs still launched fine. Only the targeted lab was locked out, which is exactly how a consumed per-user allocation looks from the victim’s side.

Skillable error returned to the colleague when launching the lab whose allocation had already been consumed under his userId
Skillable error returned to the colleague when launching the lab whose allocation had already been consumed under his userId

Worth noting that the per-user launch limit isn’t fixed and has been a recurring question in the course Discord. Before 2025, on the SnapLabs platform Zero Point Security ran, lab access was timed, with no clear limit on how many times you could launch; after the migration to Skillable each lab got its own daily launch limit, and the quota has moved around since. Course Discord discussions have described a daily launch limit of three per lab, yet the error I hit read “Sorry, you have taken this lab the maximum number of 1 times” (verbatim from the Skillable API) while a previous instance of the same lab was still active. Unsure whether this is a (new) concurrency cap (i.e. one active instance at a time) or a daily launch limit from the error message alone, but since I confirmed two concurrent instances running under different userIds the next day, the bypass removes both constraints anyway.

The exam sits on its own limit, a seven-day wait after a failed attempt with free retakes.

So what’s the actual damage:

  • Rate-limit bypass: lab launches beyond the configured per user limit.
  • Cross-user DoS: consuming another student’s lab allocation.
  • Cost impact: VM provisioning billed to the course provider; typically a domain controller plus one to three machines per instance, active 30 to 60 minutes.
  • Scale: a user-enumeration vector on the LMS would scale any of the above across the full enrolled population.

I did not test the exam endpoint. Skillable’s own customer advisory confirmed that another learner’s exam allocation could be consumed through the same mechanism, and that details of another learner’s active or saved session could be returned in a launch response. Whether exam results can be attributed to a forged userId is unknown and was not addressed by either party.

Coordinated disclosure

I raised a support ticket with Skillable on 8 May (#4043837) to get a disclosure channel, then submitted the full report to Disclosures@skillable.com on 11 May as an encrypted 7z archive, with the password sent separately against the ticket number.

The day after submitting, I shared the writeup with Daniel Duggan for visibility, since Zero Point Security’s students were the ones exposed.

Nat Shere, Skillable’s Manager of Product Security, acknowledged receipt on 13 May, called it a well-documented disclosure, and said it was under internal review. I filed the CVE request with MITRE on 19 May. A status request on 27 May went unanswered; I followed up on 8 June, restating the 60-day window that had started at acknowledgement and would close on 12 July.

The substantive response came on 10 June. Nat Shere’s position was that this was “not a technical flaw in our implementation, but an inherent flaw in the SCORM technology”, with a link to a SCORM security blog post, and a request that I not disclose “without our explicit written permission”.

I pushed back the same day and held the 12 July date.

Pushback email to Nat Shere, 10 June 2026
Pushback email to Nat Shere, 10 June 2026

MITRE reserved CVE-2026-56877 on 23 June and the technical details remained under coordinated-disclosure embargo until 12 July. I sent Skillable a coordination update on 26 June noting the assignment and the embargo date.

What Skillable told their own customers came next.

Vendor response and customer action

On 26 June, Paul Adkins, VP of Security Operations, sent at least one affected customer a security advisory titled “SCORM-based lab launches and recommended migration.” It was a customer notification rather than a public bulletin, and it had no CVE or CVSS score. They used the same framing with me during the disclosure, calling it a “security limitation affecting lab and certification exam launches” and “a long-documented characteristic of the standard.”

Skillable customer advisory - SCORM-based lab launches and recommended migration, 26 June 2026
Skillable customer advisory - SCORM-based lab launches and recommended migration, 26 June 2026

Their stated position is that there is no fix coming on the SCORM path, customers should migrate, and until then it is a “known, accepted risk.”

Why disclose what the vendor won’t fix

After Skillable’s customer advisory landed on 26 June, Daniel asked what the disclosure was going to say that anyone working with SCORM doesn’t already know. Fair question, and one worth answering properly, because “so the researcher gets another CVE badge” is not the answer. If the reason to go public were “SCORM is old and client-side”, this would be an expensive way of writing a sentence every e-learning vendor has had twenty years to get bored of.

Let’s start with the weakest reason and get it out of the way, shall we? Did the disclosure make Skillable fix anything? No. They were never going to, and the migration path already existed. If the entire case for going public was “it makes vendors patch things”, this one would fall apart.

Here is what does hold up.

The won’t-fix is actually what makes disclosure matter more, not less. If they had patched it, the case for going public would be weaker. They didn’t, so the only defence left is customer action, and customers can’t act on something they don’t know about (and obscurity doesn’t protect them since anyone can find this by testing the endpoint). Skillable’s email went to at least the customers it chose to notify at that moment, which misses future customers, the security team evaluating Skillable in 2027, the procurement and third-party-risk process that runs on CVE databases, the auditor doing vendor due diligence. None of them will see a private email, but all of them can find a CVE.

Without a public record, there is only one version of events, and it is the vendor’s. Skillable calls it an “inherent flaw in the SCORM technology” and tells customers to migrate when it suits. That is not a security decision, it is housekeeping, and housekeeping slides. SCORM does not give an external service a verified identity it can check, which is a real limitation, but it doesn’t make it safe to base allocation limits on a client controlled parameter. The bug is the server trusting a value it had no reason to trust.

Take SMTP, which has no built-in way to verify who a message is actually from either, since the From header is whatever the sender types. That is a genuine limitation, inherited from an era when every host on the network was trusted. The response was never “SMTP trusts the From field, so spoofing is just how email works.” It was SPF, DKIM, and DMARC layers added precisely because you can’t trust a client-side identifier. Skillable’s userId is the From header and blaming SCORM for not verifying identity is like blaming SMTP for not stopping spoofing: true about the protocol, and exactly why everyone else adds the controls Skillable didn’t.

This also isn't just about Skillable. Every other vendor using SCORM as a launch bridge into an external service can read this and check whether they made the same mistake call. An authorisation identifier the client supplies is an architectural risk that needs controls on the server, regardless of what the protocol provides. A confirmed CVE against a named product is what makes it land rather than stay hypothetical.

A 2022 CVE Program article explained that cloud service CVEs provide the clearest value where “the vulnerability requires customer or peer action to resolve,” contrasting that with cases where the provider silently fixes and customers have nothing to do. This is the opposite: the vendor won’t fix, so the customer must migrate.

At least one affected customer had already begun moving its lab integration to LTI once its LMS added support, while the remaining exam integration was being reviewed in light of the exposure. The point of a public advisory is to make that happen for the rest.

The route existed; the disclosure created the reason to walk it.

Where this leaves customers

CVE-2026-56877 is reserved pending publication. Once MITRE publishes, I will update this post with the link. Skillable will not add server-side identity validation to the SCORM launch path; the remediation is migration to an API-based or LTI 1.3 launch integration, and the customer advisory frames the finding as a security limitation of the SCORM standard rather than an implementation bug. I submitted the issue as CWE-639 and assessed it as CVSS 3.1 6.5 Medium (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H). That score covered the confirmed denial of service across users. Skillable later identified a possible exposure of another learner’s session data, which I did not include in my original score because I did not independently verify its content or sensitivity. Skillable is not a CNA, so MITRE issued the identifier through its CNA of Last Resort function.

A standalone technical advisory is also available on GitHub. This is the third and final post in the CRTO series, and all findings came from the same run through the RTO labs.

Timeline

Date Event
2026-04-28 userId modification via proxy regex returned a new LabInstanceId
2026-04-29 Concurrent lab instances confirmed against own account
2026-04-30 Contacted a colleague for a consented cross-user test
2026-05-03 Cross-user DoS confirmed
2026-05-08 Findings documented; support ticket #4043837; disclosure channel provided
2026-05-11 Report submitted to Disclosures@skillable.com
2026-05-12 Report shared with Daniel Duggan for visibility
2026-05-13 Skillable acknowledged (Nat Shere, Manager Product Security)
2026-05-19 MITRE CVE request submitted
2026-05-27 Status request to Skillable - no response
2026-06-08 Second follow-up, restating the 60-day disclosure window
2026-06-10 Skillable responds: “inherent flaw in the SCORM technology”; asks for no disclosure without written permission
2026-06-10 Pushback sent; open-ended embargo declined; 12 July date held
2026-06-23 MITRE assigned CVE-2026-56877
2026-06-26 Skillable customer advisory sent (Paul Adkins, VP Security Operations)
2026-06-26 Coordination update sent to Skillable
2026-07-12 Advisory published

Greg D.