The room is about a restriction everyone has written: a list of function names that may not be called, because those functions run operating-system commands or start processes. The lesson is not that the list is incomplete. It is that the list was never the right shape for the job.
A denylist names members. A runtime exposes classes. The escape is always another member of the class the list was trying to cover, reached by a mechanism whose name the list does not contain.
I paired this companion with today's field note on a sandbox whose read-only proxy forwarded method calls, because they are the same mistake at different scales. There, a boundary described fields while the attack used a channel. Here, a boundary describes function names while the attack uses a mechanism. In both cases the restriction was written correctly against the surface its author had in mind.
01 · Scope and authorisation
This companion documents the public TryHackMe Bypass Disable Functions room, an information-level web-exploitation room about PHP restrictions and upload filtering. I worked through it in my own session, everything below stays inside that environment, and I have published no flags, no quiz answers, no credentials, no target addresses and no working bypass chain. Where the room requires you to construct something, I describe the reasoning that identifies the class of mechanism to look in, so you work out the specifics yourself rather than replaying mine.
The room has an instructional section and then a challenge where you have to retrieve a file from the target. It maps to CWE-693, protection mechanism failure, and CWE-184, incomplete list of disallowed inputs, and it is one of the cleanest small laboratories I know for seeing why the second of those is a category error rather than an oversight.
02 · Learning objectives
- Read a denylist as a list of names rather than as a description of a boundary.
- Identify the class a restricted entry belongs to, and enumerate the other members of that class.
- Separate a restriction on function names from a restriction on the ability to reach an outcome.
- Recognise when two stacked denylists are being mistaken for defence in depth.
- Write up an incomplete-restriction finding as a design problem rather than a missing line in a configuration file.
- State the durable control for a case where no list can be completed.
03 · Prerequisites and the loop I use
The room assumes basic web familiarity, comfort reading PHP configuration, and the ability to upload a file and observe what the application does with it. The loop I applied is the one I use on any restricted environment:
- Observe the restriction as written, at the level of what it actually names, rather than what it appears to prevent.
- Hypothesise the class the named entries belong to, and what other members that class has.
- Choose the least invasive test that would show whether the class is constrained or only its named members.
- Interpret the result as a property of the restriction's shape, not of the specific entry that was missed.
- Update the model and decide whether the durable fix is a longer list, a different kind of control, or a change to what the process is permitted to do at all.
The reason the fourth step matters is that the tempting conclusion from this room is "they forgot one function". That reading leads to adding a line. The correct reading leads somewhere else entirely, and the room gives you enough to see the difference.
04 · The model: a denylist names members, a runtime exposes classes
Hold this apart before touching the lab, because it is the whole lesson and the rest is illustration.
A restriction of the form "these named functions may not be called" is a statement about a set of identifiers. It is exact, it is auditable, and it is easy to review, which is why it is popular. What it is not is a statement about the set of ways the protected outcome can be produced.
That set is defined by the platform: the language's own features, the extensions compiled into the runtime, the environment the process inherits, and the operating system's own facilities. It is large, it changes when the platform changes, and none of it is described by a list of function names.
So the relationship between the two is not "the list is missing entries". It is that the list and the boundary are different kinds of object. When you find a bypass here, the right description is that the restriction was expressed over the wrong surface, and the fix that follows is not another line.
05 · Stage one: read the restriction as a list of names
The room's opening step is worth slowing down, because most testers read the directive and immediately start looking for functions that were forgotten. That is the wrong search.
The productive reading is to take the named entries and ask what they have in common. In this case the answer is a family of effects: running an operating-system command, starting a process, or evaluating a string as code. Name the effect, not the function. The effect is what the author wanted to prevent, and the function list was their attempt to describe every route to it.
Once the effect is named, the question changes from "which function is missing" to "what else in this runtime can produce that effect". That is a finite question in any given deployment, and it is answerable by reading the platform rather than by guessing at names.
The observation to record at this stage is a column per restriction: the entries, the effect they share, and whether the restriction says anything about the effect itself or only about its named routes.
06 · Stage two: enumerate the mechanisms in the same class
With the effect named, the enumeration becomes tractable, and it has four families worth checking in any runtime. I am describing them at the level of mechanism, because the point is the shape of the search and not the recipe.
Another function that reaches the same effect. The most obvious family, and the one a longer list addresses. It is worth checking first because it is cheap, and it is worth recognising as the family the author was already thinking about.
A language feature that evaluates or invokes code. Runtimes that can treat a string or a structure as executable code have a route to the effect that does not involve calling a command function at all. This family is why the list cannot be completed by diligence: the entries are not functions in the same sense, and some of them are not functions.
An extension or module that exposes a process or system interface. The set of compiled extensions is a property of the deployment, so a restriction written against the core language can be silent about something an extension provides. This is also the family most likely to change under you, because extension sets change between builds and versions.
The environment the process inherits. This is the family that makes the room memorable. A process loader can be directed by environment variables to load additional code at start-up, which means the route to the effect runs through the loader rather than through any function the application calls. The restriction names functions; the loader is not a function. I will not take this further here, and I would encourage you to read the room's own material and the platform documentation rather than a walkthrough, because the mechanism is far more instructive when you assemble it yourself.
The observation to record: for each family, whether the restriction says anything about it. In most deployments the answer is yes for the first and no for the other three, and that column is the finding.
07 · Stage three: the second denylist
The room also puts a filter in front of the upload path, which is a second restriction of the same shape: it names forbidden extensions and content types and refuses them.
It is worth treating separately, because the two lists are usually reviewed by different people and each is defended on its own terms. The upload filter is a statement about names and declared types. What it does not describe is the relationship between what a file is called and what the runtime will do with it once accepted, which is the property an attacker actually needs.
The lesson is the same one at a smaller scale, and it is worth stating in the same form: the filter constrains the label, the runtime acts on the content, and the two are only related by convention.
08 · Why stacking lists is not depth
This is the part I would put in front of an architect, because the room demonstrates it cleanly.
Two denylists are often described as defence in depth. They are not, and the reason is structural. Defence in depth requires that the layers constrain different things, so that a failure in one is absorbed by another. Two lists of names constrain the same kind of thing, the naming of routes, and they fail in the same way for the same reason. A bypass of the first does not need to defeat the second; it needs to be a mechanism the second also does not name, which is likely because both lists were written by people thinking about the same class of route.
The genuine second layer for this problem is not another list. It is a constraint on what the process can do: which filesystem paths it can write, which binaries it can execute, which network destinations it can reach, and under which identity it runs. Those are properties of the environment rather than descriptions of names, and they hold regardless of which function or loader path is used.
That is also the answer to the "which line is missing" question, and it is the reason I would not accept a fix consisting of additional entries.
09 · Defensive controls
| Weakness | Durable control | What to log and review |
|---|---|---|
| A restriction expressed over names rather than effects (CWE-184) | Constrain the capability rather than the route: run the application under an identity that cannot execute process-starting binaries, and give it write access only to the paths it needs. | Changes to the process identity, the writable path set, and the set of executable binaries reachable from the application's own environment. |
| A denylist that must be completed by hand | Treat the list as a mitigation with a known shape, not as the boundary, and record that it is a list of names so reviewers do not over-trust it. | The date each restriction was last tested against the platform version in use, since the mechanism set changes when the runtime does. |
| Two stacked lists mistaken for depth | Require the layers to constrain different properties: one on identity and reach, one on content, and verify that a failure in either is absorbed. | Which property each layer constrains, written down, so a reviewer can see whether two layers are actually two. |
| Upload filtering on labels rather than content | Validate what the file is and what the runtime will do with it, rather than what it is called, and store uploads where they cannot be executed. | Uploads whose declared type disagrees with their content, and any execution from a writable directory. |
| Environment inherited by the process | Run with a minimal, explicitly defined environment rather than an inherited one, so loader behaviour cannot be redirected by anything the application can set. | Changes to the environment the application process starts with, and any environment variable the application can write. |
The first row is the one that matters, and its shape is worth internalising: the durable control here is not a longer list but a smaller capability. That is a more invasive change than adding a line, which is exactly why the line keeps getting added instead.
10 · Misconceptions and troubleshooting
- "They forgot a function." They may have. But the reason the room works is not a missing entry, it is that a list of names cannot describe the class of mechanisms that produce the effect.
- "Adding the missed entry fixes it." It closes the demonstrated route and leaves the class open. The next mechanism is not in the list either, and there is no version of the list where it is.
- "Two filters are defence in depth." Only if they constrain different properties. Two lists of names fail together because they are the same kind of control.
- "The upload filter is a separate problem." It is the same problem at a smaller scale: a statement about labels, against a runtime that acts on content.
- "This is a PHP problem." Every runtime with an environment, a loader and extensions has the same shape. The room uses one platform because a laboratory needs one.
Troubleshooting comes down to the effect. When you are stuck against a restriction, stop looking for the missing name and write down the outcome you need, then ask what else in this deployment can produce it. That reframing is what the room is teaching, and it is the transferable part.
11 · Memory aids and practice checklist
Memory aid: a list names members, a runtime exposes classes. When you meet a denylist, the productive question is never "what is missing from this list" but "what class is this list trying to cover, and what else is in that class".
- Read the restriction for the effect its entries share, not for the entries.
- Name the effect, then enumerate the mechanisms that produce it in this deployment.
- Check the four families: other functions, code-evaluation features, extensions, and the inherited environment.
- Treat a second denylist as a second list of names until you can show it constrains something different.
- For upload paths, check what the runtime does with the content rather than what the file is called.
- State the durable fix as a reduced capability, not an added entry.
- Record the platform version a restriction was last tested against, because the mechanism set moves with the runtime.
12 · What the room teaches
The lesson, once: you cannot describe a capability boundary by naming the routes you know about. The list is not wrong because it is short. It is the wrong instrument, and the room demonstrates that with a restriction almost every PHP deployment has carried at some point.
Read alongside the rest of this series it forms a set with today's field note on a sandbox whose read-only proxy forwarded method calls, where a boundary described fields and the attack used a channel, and with the note on detection that could not separate two behaviours. Three layers, one question: what is this control actually able to express.
Frequently asked questions
What is the disable_functions directive for?
It lets an administrator list PHP functions that may not be called, typically the ones that run operating-system commands, start processes or evaluate code. Its purpose is to reduce the impact of a compromised application by removing the most direct routes to command execution. It is a real mitigation and it raises the cost of exploitation, which is why it is widely used, and it is also a list of names.
Why can a denylist of functions be bypassed?
Because it constrains identifiers while the outcome depends on mechanisms. A runtime offers several ways to reach command execution: other functions, features that evaluate code, compiled extensions, and the environment the process inherits and the loader honours. The directive names none of those except by their function names, so a route that does not go through a named function is not affected by it.
What is the correct fix?
Constrain the capability rather than the route. Run the application under an identity that cannot execute process-starting binaries, restrict the paths it can write, restrict the destinations it can reach, and give the process a minimal defined environment rather than an inherited one. Those are properties of the deployment and they hold whichever mechanism an attacker reaches for, which is what a list of names cannot promise.
Is this the same problem as an incomplete patch?
Related but distinct, and the difference matters for the write-up. An incomplete patch is a fix scoped too narrowly to one demonstrated route, which is a process failure. This is a control that was the wrong instrument from the start, which is a design failure. Both end with someone adding a line, and only one of them is fixed by doing so.