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 search
This time the starting point was a perspective, not a technique: I looked at ILIAS consistently from the point of view of someone who cannot log in. The question is then no longer “what may a participant do, what may a course administrator do?”. It becomes: what runs at all before the application knows who is asking?
This view is taken less often in testing than you might think. As soon as an account is involved, everything revolves around roles and permissions – who may see what, who may change what. Before login, that question does not exist. There is only the other one: who is being asked here in the first place? The surface is smaller and can therefore be gone through completely.
In practice, that means going through the list of contexts that the initialisation exempts from the login requirement and reading up, for each entry, on what happens behind it. That list is short. What happens behind it is not.
One of the entries caught my attention – because of a pattern from the same family as
the string concatenation I had found earlier: where does stored state turn
back into an object? In other words unserialize(), plus the
follow-up question of whether there is any restriction on which classes may be created
along the way.
The hit was in the Shibboleth component, in a back-channel called
shib_logout.php. Through this route, an identity provider tells the
application that a login has been ended elsewhere. The application then has to work
out which of its own sessions is meant – and it went about this generously: it read
all still-active sessions from the database and passed their stored contents through
a home-grown parsing function. That function called unserialize()
without setting the parameter that restricts the permitted classes.
Why this is dangerous even though it only reads
unserialize() does not read data – it builds objects. These
objects belong to real classes of the application, and their lifecycle methods run
even if the program never uses them: at the latest when they are discarded, cleanup
happens. A project that has grown over time almost always brings along, through its
dependencies, a class that does something consequential at that point – writing a
file, issuing a command, opening a connection. So the attacker writes no code. They
put existing building blocks together so that their perfectly normal behaviour
achieves the desired effect.
With that, the sink was found. What was missing was the way there: how does someone who is not logged in get anything into a session of this instance at all?
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.
- 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.
- 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.
- The contents are deserialised without class restriction Stored text becomes objects of any class the application knows – including those from bundled third-party libraries.
- 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.
- 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:
| 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.