2022-02-22 - debugging options with pools Two goals: - help developers spot bugs as early as possible - make the process more reliable in field, by killing sick ones as soon as possible instead of letting them corrupt data, cause trouble, or even be exploited. An allocated object may exist in 5 forms: - in use: currently referenced and used by haproxy, 100% of its size are dedicated to the application which can do absolutely anything with it, but it may never touch anything before nor after that area. - in cache: the object is neither referenced nor used anymore, but it sits in a thread's cache. The application may not touch it at all anymore, and some parts of it could even be unmapped. Only the current thread may safely reach it, though others might find/release it when under thread isolation. The thread cache needs some LRU linking that may be stored anywhere, either inside the area, or outside. The parts surrounding the parts remain invisible to the application layer, and can serve as a protection. - in shared cache: the object is neither referenced nor used anymore, but it may be reached by any thread. Some parts of it could be unmapped. Any thread may pick it but only one may find it, hence once grabbed, it is guaranteed no other one will find it. The shared cache needs to set up a linked list and a single pointer needs to be stored anywhere, either inside or outside the area. The parts surrounding the parts remain invisible to the application layer, and can serve as a protection. - in the system's memory allocator: the object is not known anymore from haproxy. It may be reassigned in parts or totally to other pools or other subsystems (e.g. crypto library). Some or all of it may be unmapped. The areas surrounding the parts are also part of the object from the library's point of view and may be delivered to other areas. Tampering with these may cause any other part to malfunction in dirty ways. - in the OS only: the memory allocator gave it back to the OS. The following options need to be configurable: - detect improper initialization: this is done by poisonning objects before delivering them to the application. - help figure where an object was allocated when in use: a pointer to the call place will help. Pointing to the last pool_free() as well for the same reasons when dealing with a UAF. - detection of wrong pointer/pool when in use: a pointer to the pool before or after the area will definitely help. - detection of overflows when in use: a canary at the end of the area (closest possible to ) will definitely help. The pool above can do that job. Ideally, we should fill some data at the end so that even unaligned sizes can be checked (e.g. a buffer that gets a zero appended). If we just align on 2 pointers, writing the same pointer twice at the end may do the job, but we won't necessarily have our bytes. Thus a particular end-of-string pattern would be useful (e.g. ff55aa01) to fill it. - detection of double free when in cache: similar to detection of wrong pointer/pool when in use: the pointer at the end may simply be changed so that it cannot match the pool anymore. By using a pointer to the caller of the previous free() operation, we have the guarantee to see different pointers, and this pointer can be inspected to figure where the object was previously freed. An extra check may even distinguish a perfect double-free (same caller) from just a wrong free (pointer differs from pool). - detection of late corruption when in cache: keeping a copy of the checksum of the whole area upon free() will do the job, but requires one extra storage area for the checksum. Filling the area with a pattern also does the job and doesn't require extra storage, but it loses the contents and can be a bit slower. Sometimes losing the contents can be a feature, especially when trying to detect late reads. Probably that both need to be implemented. Note that if contents are not strictly needed, storing a checksum inside the area does the job. - preserve total contents in cache for debugging: losing some precious information can be a problem. - pattern filling of the area helps detect use-after-free in read-only mode. - allocate cold first helps with both cases above. Uncovered: - overflow/underflow when in cache/shared/libc: it belongs to use-after-free pattern and such an error during regular use ought to be caught while the object was still in use. - integrity when in libc: not under our control anymore, this is a libc problem. Arbitrable: - integrity when in shared cache: unlikely to happen only then if it could have happened in the local cache. Shared cache not often used anymore, thus probably not worth the effort - protection against double-free when in shared cache/libc: might be done for a cheap price, probably worth being able to quickly tell that such an object left the local cache (e.g. the mark points to the caller, but could possibly just be incremented, hence still point to the same code location+1 byte when released. Calls are 4 bytes min on RISC, 5 on x86 so we do have some margin by having a caller's location be +0,+1,+2 or +3. - underflow when in use: hasn't been really needed over time but may change. - detection of late corruption when in shared cache: checksum or area filling are possible, but is this as relevant as it used to considering the less common use of the shared cache ? Design considerations: - object allocation when in use must remain minimal - when in cache, there are 2 lists which the compiler expect to be at least aligned each (e.g. if/when we start to use DWCAS). - the original "pool debugging" feature covers both pool tracking, double- free detection, overflow detection and caller info at the cost of a single pointer placed immediately after the area. - preserving the contents might be done by placing the cache links and the shared cache's list outside of the area (either before or after). Placing it before has the merit that the allocated object preserves the 4-ptr alignment. But when a larger alignment is desired this often does not work anymore. Placing it after requires some dynamic adjustment depending on the object's size. If any protection is installed, this protection must be placed before the links so that the list doesn't get randomly corrupted and corrupts adjacent elements. Note that if protection is desired, the extra waste is probably less critical. - a link to the last caller might have to be stored somewhere. Without preservation the free() caller may be placed anywhere while the alloc() caller may only be placed outside. With preservation, again the free() caller may be placed either before the object or after the mark at the end. There is no particular need that both share the same location though it may help. Note that when debugging is enabled, the free() caller doesn't need to be duplicated and can continue to serve as the double-free detection. Thus maybe in the end we only need to store the caller to the last alloc() but not the free() since if we want it it's available via the pool debug. - use-after-free detection: contents may be erased on free() and checked on alloc(), but they can also be checksummed on free() and rechecked on alloc(). In the latter case we need to store a checksum somewhere. Note that with pure checksum we don't know what part was modified, but seeing previous contents can be useful. Possibilities: 1) Linked lists inside the area: V size alloc ---+------------------------------+-----------------+-- in use |##############################| (Pool) (Tracer) | ---+------------------------------+-----------------+-- ---+--+--+------------------------+-----------------+-- in cache |L1|L2|########################| (Caller) (Sum) | ---+--+--+------------------------+-----------------+-- or: ---+--+--+------------------------+-----------------+-- in cache |L1|L2|###################(sum)| (Caller) | ---+--+--+------------------------+-----------------+-- ---+-+----------------------------+-----------------+-- in global |N|XXXX########################| (Caller) | ---+-+----------------------------+-----------------+-- 2) Linked lists before the the area leave room for tracer and pool before the area, but the canary must remain at the end, however the area will be more difficult to keep aligned: V head size alloc ----+-+-+------------------------------+-----------------+-- in use |T|P|##############################| (canary) | ----+-+-+------------------------------+-----------------+-- --+-----+------------------------------+-----------------+-- in cache |L1|L2|##############################| (Caller) (Sum) | --+-----+------------------------------+-----------------+-- ------+-+------------------------------+-----------------+-- in global |N|##############################| (Caller) | ------+-+------------------------------+-----------------+-- 3) Linked lists at the end of the area, might be shared with extra data depending on the state: V size alloc ---+------------------------------+-----------------+-- in use |##############################| (Pool) (Tracer) | ---+------------------------------+-----------------+-- ---+------------------------------+--+--+-----------+-- in cache |##############################|L1|L2| (Caller) (Sum) ---+------------------------------+--+--+-----------+-- ---+------------------------------+-+---------------+-- in global |##############################|N| (Caller) | ---+------------------------------+-+---------------+-- This model requires a little bit of alignment at the end of the area, which is not incompatible with pattern filling and/or checksumming: - preserving the area for post-mortem analysis means nothing may be placed inside. In this case it could make sense to always store the last releaser. - detecting late corruption may be done either with filling or checksumming, but the simple fact of assuming a risk of corruption that needs to be chased means we must not store the lists nor caller inside the area. Some models imply dedicating some place when in cache: - preserving contents forces the lists to be prefixed or appended, which leaves unused places when in use. Thus we could systematically place the pool pointer and the caller in this case. - if preserving contents is not desired, almost everything can be stored inside when not in use. Then each situation's size should be calculated so that the allocated size is known, and entries are filled from the beginning while not in use, or after the size when in use. - if poisonning is requested, late corruption might be detected but then we don't want the list to be stored inside at the risk of being corrupted. Maybe just implement a few models: - compact/optimal: put l1/l2 inside - detect late corruption: fill/sum, put l1/l2 out - preserve contents: put l1/l2 out - corruption+preserve: do not fill, sum out - poisonning: not needed on free if pattern filling is done. try2: - poison on alloc to detect missing initialization: yes/no (note: nothing to do if filling done) - poison on free to detect use-after-free: yes/no (note: nothing to do if filling done) - check on alloc for corruption-after-free: yes/no If content-preserving => sum, otherwise pattern filling; in any case, move L1/L2 out. - check for overflows: yes/no: use a canary after the area. The canary can be the pointer to the pool. - check for alloc caller: yes/no => always after the area - content preservation: yes/no (disables filling, moves lists out) - improved caller tracking: used to detect double-free, may benefit from content-preserving but not only.