It’s not difficult. Most modern allocators are pretty similar, and quite predictable. They keep a cache (usually per-thread or per-CPU) of free allocations for each allocation size[1], in the form of a stack. malloc() pops the last allocation from the stack, and free() pushes onto it. If you malloc() when the stack is empty, there’s some more logic to obtain a new chunk of allocations, but that’s not relevant here. Once the target (kernel in this case) frees an object, the allocation will be at the top of the stack, and whatever object it allocates next with the same size, on the same thread/CPU, will likely take its place. In the case of a kernel, most allocations are in response to syscalls or other requests from userland, so you can usually control what gets allocated next.
There usually is some risk of the allocator not doing what you expect, e.g. because of intervening mallocs/frees from other threads, which indeed is likely to lead to a crash. However, most exploits don’t need to be 100% reliable anyway. And there are various ways to increase reliability.
Caveat: Not all allocators work this way, and even among the ones that do, each has its unique quirks. However, the basic “first in, first out” behavior is quite common across a multitude of implementations. IIRC, the default Linux allocator (it has three to choose from!) is one of them.
[1] Actually range of allocation sizes. Such allocators will usually have a fixed list of “size classes”, which are just specific allocation sizes they support; allocation requests that fall between size classes will just be rounded up to the next highest one. Large allocations (e.g. 4kb+) are handled with a different path.