Who gets to ring the bell here?

Every application that has a login also has exceptions to it. That is how it has to be: the login page itself must be reachable without logging in, so must the forgotten-password function, and the legal notice anyway. Then there are the less conspicuous cases – interfaces where it is not a person knocking but another server.

That is where it gets interesting. When a foreign system sends a message, it cannot log in like a user; it is not one. So the login requirement is switched off for this path. The decision is correct, and it has a property you cannot see at a glance: it applies to everything that runs in this context – including code added years later that knows nothing of this history.

For a test, that is a rewarding starting question, and it can be answered from the source code rather than the browser: which entry points does the initialisation exempt from the login requirement – and what happens behind them? The list is short, finite and in one place.

Every exception to the login requirement is a place where the usual assumptions no longer hold. You should know them before someone else does.

The chain, link by link

As with the SQL injection examined earlier: a finding in the source code is a hypothesis. It only becomes solid once every link can be verified individually – and once the question has been asked of each one whether it might break the chain after all.

  1. The endpoint is open The Shibboleth back-channel runs in a context that the central initialisation exempts from the login requirement. No account, no token, no session needed.
  2. It reads all sessions, not just one To determine the matching session, the entire table of still-valid sessions is walked through. So it is enough for a prepared session to exist somewhere – it does not have to belong to the caller.
  3. The contents are deserialised without class restriction Stored text becomes objects of any class the application knows – including those from bundled third-party libraries.
  4. The contents got there without login The missing piece was in a completely different corner: the entry point of the LTI integration stores submitted values in the session – and is likewise exempted from the login requirement by the same initialisation. An anonymous session with content of your choosing is therefore a single request away.
  5. Cleanup means writing Among the bundled libraries is a class whose normal behaviour on being discarded is to write a file – to a path taken from one of its own properties.

Five links, none of which is a bug on its own. The exception from the login requirement is correct. The back-channel must be able to find sessions. The LTI integration must cache values. The library class does exactly what it was built for. Only the combination produces the finding – and turns five defensible decisions into code execution without login.

Three lines, three answers

This is where the test lab pays off. On the VM, all actively maintained versions sit side by side, and the question “does this affect all of them?” is not an estimate but an afternoon’s work. In this case the answer was more remarkable than expected.

The vulnerable code – the deserialisation without class restriction – is present in all three lines, and essentially identical. Whether the endpoint actually reaches it as shipped, however, differs:

Behaviour of the endpoint in each line as shipped
Line Vulnerable code Reachable
ILIAS 9 present No. The relevant branch depends on a superglobal that PHP 7 removed. It is never entered.
ILIAS 10 present Yes – demonstrated as part of the report.
ILIAS 11 present No. The endpoint accesses the service container before it is built, and aborts before that point on every request.

It is worth being clear about what rows one and three say: there, the Shibboleth back-channel was not functional at all. Not secured – broken. In one line because it relies on a PHP variable that has not existed for more than ten years. In the other because a line of code accesses something that does not yet exist at that point, so every request aborts immediately.

Had the Shibboleth logout ever been used there, someone would have noticed. Nobody noticed. For years.

No reason to stand down

This is explicitly not an argument for postponing an update. What blocks the endpoint in these lines are incidental circumstances – a language change and a programming error – not protective measures. A patch, a plugin or a customised installation can remove them without anyone noticing. The vulnerable code is present everywhere, and that is why the vendor removed it everywhere.

What you won’t find here

With the SQL injection, I could show the source code and trace the path of the value link by link. Here I am not doing that, and that is a deliberate decision.

The difference is not in the finding itself. It is in what it costs. The SQL injection required a logged-in account with write permission – someone first had to be a course administrator somewhere. Code execution without login, by contrast, can be run automatically against every reachable instance, and after a patch it takes weeks to months until the majority of installations are updated. A step-by-step account would be a construction manual during that time.

That is why there is no source code here, no name of the library class suitable as a tool, no parameters and no sequence of concrete requests. What is here is enough to assess whether you are affected, to judge the urgency correctly and to analyse log data sensibly. Nothing more is here. If you want to read the code, you will find it via the vendor’s commit linked in Advisory SIT-2026-002.

This trade-off does not come out the same for every finding. It depends on how easily something can be exploited, how many systems are affected and how long an update takes to reach the installed base.

The fix

The vendor did not secure the endpoint’s logout handling but removed it. The function was tied to an operating mode that only some instances use, and its task – finding the matching local session from a foreign notification – can hardly be solved sensibly without going through all session data. The formal details are in Advisory SIT-2026-002.

Conclusion / outlook

The finding examined earlier was a forgotten check. This one is something else: a function nobody uses any more, at an entrance nobody checks any more. It was broken in two of three lines, and for years nobody noticed.

A function whose failure nobody notices is no longer a function – it is just attack surface. It is not used, so it is not tested; it is not tested, so nobody notices that it is broken; and because nobody misses it, nobody looks at its code either. It just sits there and waits.

I take away two points, and both apply far beyond ILIAS:

  • Exceptions to the login requirement age badly. They are set up for a good reason and afterwards apply to everything that runs in that context. Keeping a list of these exceptions and reviewing it regularly is one of the most rewarding exercises there is.
  • Session data is not your own data. As soon as any path that requires no login can write to it, its contents are external input – and the question “who can actually store something here?” belongs at every place that reads stored state back in.

The perspective of this writeup – looking at the application from outside, without an account – has yielded more than this one finding. Further posts on it are being held back for now.