0
Выберите свой язык
0
Выберите свой язык

Чтение и запись рулевой рейки BMW с ЭУР (EPS) по FlexRay

Чтение и запись рулевой рейки BMW с ЭУР (EPS) по FlexRay
06.10.2026
Делиться

Небольшой агент под MPC5643L, работающий прямо в рейке: читает и пишет флеш и EEPROM рейки по FlexRay — без JTAG и без вскрытия корпуса.

Заметки со стенда, со стороны ремонта. Всё описанное выполнялось на снятых с машины узлах на моём собственном стенде — как работа по восстановлению и диагностике, и всё это получено из прошивки: образ открыт в Ghidra и дизассемблирован, выверен по моим собственным дампам и по большому объёму известного открытого текста. Заводской защищённый доступ (security access) OEM я не публикую — это не моё, и в агенте его всё равно нет.

Коротко

Блок управления рулевым управлением у заметной части модельного ряда BMW F-серии (внутренний тег загрузчика 000019EE, далее «19EE») построен на NXP MPC5643L — двухъядерном PowerPC с режимом lockstep и системой команд VLE, а его калибровочные данные разделены между встроенной флеш C90FL и внешней SPI EEPROM M95640. Заводское ПО общается с ним по FlexRay.

Я хотел читать и писать обе памяти на стенде, по FlexRay, не подключая JTAG-пробник к каждому отдельному узлу. Путь к этому — крошечный автономный агент, загружаемый в SRAM рейки по FlexRay через механизм загрузки, который я откопал в прошивке с помощью Ghidra; агент отвечает на операции с памятью, отправляя данные обратно на мой кабель. Этот материал разбирает агент: обвязку FlexRay, драйверы EEPROM и флеш, а также один протокольный приём, который вылечил нестабильное чтение.

Стенд 19EE: блок MS561, кабель FlexRay, рейка и осциллограф

стенд. Блок MS561 для ЭУР (питание и диагностика), кабель FlexRay, рейка 19EE, ThinkPad с запущенным MS561 и осциллограф сверху.

Объект

MPC5643L — это MCU с упором на функциональную безопасность: два ядра e200z4 в режиме lockstep, ECC повсюду и встроенный на кристалл FlexRay-контроллер E-Ray от Freescale/NXP. Цифры, которые важны для всего дальнейшего:

Что Где
Ядро e200z4, PowerPC VLE (переменная длина команд), big-endian
Флеш кода контроллер C90FL по адресу 0xC3F88000, флеш отображена на базовый адрес 0x0
«block0» 0x0000–0x3FFF (16 КБ) — калибровка + конфигурационная CRC-32
EEPROM внешняя M95640 (64 Кбит, SPI) на DSPI_B по адресу 0xFFF94000
FlexRay контроллер связи (CC) E-Ray от NXP по адресу 0xFFFE0000, message RAM в системной SRAM

Один подвох с VLE сразу: большинство штатных сборок objdump не декодируют VLE — binutils образца 2005 года выдаёт мусор. Статический анализ я делал в Ghidra с языком PowerPC:BE:64:VLE-32addr. Ошибётесь с режимом — и весь образ декодируется в бессмыслицу.

Кристалл рейки SPC5643LFMLQ1 (MPC5643L) и EEPROM M95640

чип. Основной кристалл рейки — SPC5643LFMLQ1, тот самый MPC5643L — а слева от него маленькая 8-выводная SPI EEPROM M95640.

Почему агент, а не «просто JTAG по всему»

Долгое время единственным способом добраться до этих данных была грубая «хирургия»: вскрыть корпус блока управления, выпаять EEPROM, прочитать и перепрограммировать её на дешёвом внешнем программаторе, а потом впаять обратно. Способ работает — многие мастерские до сих пор так и делают, — но он медленный и капризный, и каждый такой цикл — очередной шанс оторвать контактную площадку или перегреть микросхему. JTAG аккуратнее, и именно его я использовал на этапе реверс-инжиниринга, но он всё равно означает вскрытие герметичного корпуса ради доступа к отладочным площадкам. Ни то, ни другое не хочется повторять с узлом за узлом на стенде.

JTAG-программатор P&E на отладочном разъёме рейки на этапе реверс-инжиниринга

этап RE. Как я читал её при реверс-инжиниринге: корпус вскрыт, JTAG-программатор P&E на отладочном разъёме рейки. Для лаборатории — нормально, но повторять с каждым узлом не хочется.

Агент избавляет от всего этого. Он заходит внутрь по FlexRay — по шине, которая и так выведена на разъём, — так что узел остаётся закрытым: не нужно вскрывать корпус, выпаивать микросхему и отрывать площадки.

Копаясь в прошивке в Ghidra, я нашёл, что механизм уже встроен: в загрузчике есть путь, который затягивает небольшую программу в свободную SRAM рейки по FlexRay и запускает её. Поэтому я написал собственный агент под этот путь, задача которого проста:

опрашивать слот FlexRay на предмет команд, выполнять операцию с памятью и отвечать в другом слоте.

Ни libc, ни RTOS, ни зависимостей. Указатель стека и точку входа он получает от загрузчика, выдаёт сигнатуру «я жив», чтобы я мог убедиться, что работает именно мой код, и затем крутится в цикле обработки команд.

Шаг первый: разблокировка инженерного режима

Прежде чем всё это заработает, рейку нужно перевести в её инженерный режим, а он закрыт защищённым доступом UDS (security access). Последовательность — обычный UDS:

  1. DiagnosticSessionControl в расширенную сессию (10 03), затем в инженерную сессию (10 42).
  2. SecurityAccess — уровень «13/14»: запрос seed (27 13), рейка возвращает 8-байтовый seed; вычисляем ответ; отправляем ключ (27 14). Ключ — это 4-байтовое поле длины, за которым следует 128-байтовая подпись.
  3. RoutineControl Start (31 01 03 0C) — именно это и взводит инженерный режим. Процедуру 030C я зафиксировал в Ghidra; её диспетчер RoutineControl находится по адресу 0x635a0.

Единственное, что я оставляю при себе, — средний шаг: как формируется эта подпись и какой за ней стоит ключ. Это остаётся на моём стенде. Как её «варит» BMW? Идите спрашивайте у них. 😉

Шаг второй: как доставить агент в чип

В прошивке есть загрузчик, который принимает кусок кода в свободную SRAM по FlexRay и запускает его. Я разобрал его в Ghidra:

  • Команда SETUP (00 03) переводит приёмную задачу загрузчика (задача RTOS; её диспетчер по адресу 0x80000, таблица обработчиков по 0x40009098) в режим приёма; рейка отвечает подтверждением (00 08 51).
  • Приёмник кадров по адресу 0x8c664 копирует входящие данные в SRAM только если признак состояния по адресу 0x7e40 взведён в 0xFE/0xFF. Иначе каждый кадр молча отбрасывается.
  • Взводит этот признак сам вход в инженерный режим: сессия UDS устанавливает байт состояния (0x7944) в 1, RoutineControl 030C отрабатывает свой обработчик, а процедура входа по адресу 0x81010 записывает 0x7e40 = 0xFE и запускает задачу.

Подвох, стоивший мне времени: SETUP принимается, подтверждение возвращается, но пакет всё равно не «долетает». Дамп RAM рейки (ICDPPCNEXUS Hotsync, без сброса) показал 0x7e40 = 0 — приёмная задача не была взведена, поэтому каждый кадр отваливался. Слать быстрее не помогает: приёмник должен быть взведён и оставаться взведённым к моменту прихода потока, так что порядок такой: взвести 030C, дождаться подъёма кластера FlexRay, затем выдать пакет. Пока признак удерживается, код попадает в SRAM, запускается, и агент заявляет о себе своим кадром «я жив» (00 4C 8A A0 A1 … BF). Без JTAG, корпус закрыт.

FlexRay глазами E-Ray

Всё проходит через два буфера сообщений FlexRay (message buffers, MB): один агент слушает, в другом отвечает. Message RAM контроллера E-Ray — это массив заголовков плюс область данных, а конфигурация буфера лежит в регистрах по адресу ERAY_BASE + 0x100 + idx*8.

Кабель захватывает линию FlexRay RX рейки

на линии. Кабель снимает линию FlexRay RX рейки, пока я разбирался с форматом кадров — здесь запись в реальном времени на ~580 тыс. фронтов/с при частоте дискретизации 275 МГц.

eng_agent.c — карта буферов сообщений E-Ray

#define ERAY_BASE   0xFFFE0000u          /* E-Ray CC; MVR @ +0 reads 0xA268 */
#define FR_MEMBASE  0x40002A00u          /* message RAM base = SYMBADHR:SYMBADLR */

#define MBCCSR(i)  (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 0u))
#define MBCCFR(i)  (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 2u))
#define MBFIDR(i)  (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 4u))
#define MBIDXR(i)  (*(volatile u16 *)(ERAY_BASE + 0x100u + (u32)(i)*8u + 6u))
#define MEM16(off) (*(volatile u16 *)(FR_MEMBASE + (u32)(off)*2u))

/* MBCCSR bits */
#define MB_MTD   0x1000u   /* transmit direction */
#define MB_CMT   0x0800u   /* commit (TX)        */
#define MB_LCKT  0x0200u   /* lock toggle        */
#define MB_DVAL  0x0008u   /* data valid (RX)    */
#define MB_LCKS  0x0002u   /* locked status      */
#define MB_MBIF  0x0001u   /* interrupt flag     */

#define RX_MB 2u           /* I listen on slot 3 (frameID 3 = MB index 2) */
#define TX_MB 0u           /* I answer on  slot 1 (frameID 1 = MB index 0) */
#define KEEP  0xF900u      /* config bits I must preserve on every CSR write */

Чтобы обратиться к буферу, его нужно заблокировать (переключить LCKT и убедиться, что LCKS вернулся установленным). Вот что меня здесь укусило:

Передающий MB аппаратно повторно передаёт своё содержимое в каждом цикле FlexRay. Пока контроллер копирует буфер на шину, блокировка принадлежит ему, и одна попытка «переключил и надеюсь» проигрывает гонку чаще, чем хотелось бы.

Наивная одноразовая блокировка молча возвращает «не получил», ответ так и не уходит, дальний конец видит, что блок замолчал, и чтение обрывается или ползёт, пока другая сторона снова и снова переспрашивает. Решение — крутиться в цикле, пока контроллер не отдаст буфер: свободное окно есть в каждом цикле. Повторять безопасно: отклонённая запись LCKT просто игнорируется (статус не меняется), так что состояние не испортишь, и ты останавливаешься в тот же миг, когда LCKS считывается установленным, — поэтому никогда не снимешь блокировку, которую уже держишь.

mb_lock — крутимся, пока CC не отдаст буфер

/* Lock a MB. Return 1 if locked, 0 if the CC never yielded it.
   The TX MB retransmits every cycle and the CC owns it while copying out,
   so a single LCKT write often loses the race. Spin until a free window. */
static int mb_lock(u32 i)
{
    u32 spin;
    for (spin = 0; spin < 200000u; spin++) {
        MBCCSR(i) = (u16)((MBCCSR(i) & KEEP) | MB_LCKT);
        if (MBCCSR(i) & MB_LCKS) return 1;
    }
    return 0;
}
static void mb_unlock(u32 i)  { MBCCSR(i) = (u16)((MBCCSR(i) & KEEP) | MB_LCKT); }
static void mb_clrflag(u32 i) { MBCCSR(i) = (u16)((MBCCSR(i) & KEEP) | MB_MBIF); }

Тогда опрос команды выглядит так: заблокировать, проверить DVAL (новый кадр?), прочитать заголовок, чтобы узнать, где лежит полезная нагрузка и какой она длины, скопировать её, разблокировать. Отправка ответа — зеркальное отражение.

rx_poll / tx_send — одна команда на вход, один ответ на выход

/* Poll the RX slot. On a new frame copy up to maxw words into dst, return #words. */
static int rx_poll(u16 *dst, int maxw)
{
    u16 idx, hdr, dataoff, len, p;
    if (!mb_lock(RX_MB)) return 0;
    if (!(MBCCSR(RX_MB) & MB_DVAL)) { mb_unlock(RX_MB); return 0; }
    mb_clrflag(RX_MB);
    idx     = MBIDXR(RX_MB);
    hdr     = (u16)(idx * 5u);                   /* header = idx*5 halfwords */
    dataoff = (u16)(MEM16(hdr + 3) / 2u);        /* header[3] = data byte offset */
    len     = (u16)(MEM16(hdr + 1) & 0x7Fu);     /* header[1] low 7 bits = words */
    if (len > maxw) len = (u16)maxw;
    for (p = 0; p < len; p++) dst[p] = MEM16(dataoff + p);
    mb_unlock(RX_MB);
    return (int)len;
}

/* Write the response words into the TX slot and commit. */
static void tx_send(const u16 *src, int words)
{
    u16 idx, hdr, dataoff; int p;
    if (!mb_lock(TX_MB)) return;    /* CC busy: skip; the far side re-asks (robust) */
    mb_clrflag(TX_MB);
    idx     = MBIDXR(TX_MB);
    hdr     = (u16)(idx * 5u);
    dataoff = (u16)(MEM16(hdr + 3) / 2u);
    for (p = 0; p < words; p++) MEM16(dataoff + p) = src[p];
    MBCCSR(TX_MB) = (u16)((MBCCSR(TX_MB) & KEEP) | MB_CMT);   /* commit */
    mb_unlock(TX_MB);
    mb_clrflag(TX_MB);
}

Приём, сделавший чтение надёжным: эхо в ответе

У поведения «повторяет каждый цикл» есть и второе следствие. Поскольку TX-буфер продолжает выкладывать своё последнее содержимое на шину, приёмник может защёлкнуть устаревшую копию предыдущего ответа — особенно при первом чтении после смены состояния: ты оказываешься на один запрос позади, спрашиваешь адрес N, а получаешь байты для N-1, и выглядит это почти правильно — ровно до того момента, когда становится совсем неправильно.

Задержки на установление и повторы это маскируют, но это гонка, и это медленно. Детерминированное решение — сделать каждый ответ самоидентифицирующимся. Агент вставляет в кадр два дополнительных слова: эхо-маркер (запрошенный адрес либо фиксированное контрольное значение для записи) и магическую метку ответа. После этого кабель отвергает любой ответ, эхо которого не совпадает с только что отправленным запросом. Никакого тайминга, никаких догадок — неправильный кадр отбрасывается структурно.

build_response — кадр ответа + эхо-метка

#define REPLY_MAGIC  0x4321u     /* "this is a real data reply" tag */
#define WRPOS_ECHO   0x5A5Au     /* sentinel for the destructive write reply */

/* Build "00 4C 8A <32 data bytes>" from 32 source bytes into the frame buffer. */
static void build_response(u16 *frame, const u8 *data32)
{
    u8 b[36]; int i;
    b[0] = 0x00; b[1] = 0x4C; b[2] = 0x8A;        /* my fixed reply header */
    for (i = 0; i < 32; i++) b[3 + i] = data32[i];
    for (i = 0; i < 62; i++) frame[i] = 0;
    /* pack byte pairs little-end-first into each MB halfword (verified on the bench) */
    for (i = 0; i < 18; i++) frame[i] = (u16)((b[2*i + 1] << 8) | b[2*i]);
}

/* ...and at each call site, right before tx_send: */
frame[18] = (u16)(addr & 0xFFFFu);  /* echo the requested address  */
frame[19] = REPLY_MAGIC;            /* tag it as a fresh data reply */

С эхом на месте полные чтения по 16 КБ каждый раз возвращаются целиком, а не обрываются на полпути. Если вы когда-нибудь воевали со статическими слотами FlexRay, вы понимаете, какую плавающую ошибку это убивает.

MS561 читает flash block0 — чтение OK, целостность OK

чтение. Полное чтение flash block0 в MS561: 16 КБ получено, hex на экране, и консоль завершается строками flash block0 read OK (16384 bytes) и Flash integrity OK. Агент поднят (Agent connected), и на узле нет JTAG.

Чтение EEPROM — возвращаем к жизни выводы SPI

M95640 висит на DSPI_B. Подвох: прошивка читает её один раз при загрузке, а затем снимает мультиплексирование с выводов, так что к моменту запуска агента эти выводы — уже не SPI. Первым делом их нужно вернуть обратно: назначить CS/SCK/SOUT как выходы, а SIN как вход через регистры управления выводами SIUL, затем поднять DSPI_B как медленный, безопасный 8-битный мастер. (Номера выводов и CTAR взяты прямо из собственной загрузочной процедуры работы с EEPROM в рейке.)

Классическое чтение SPI-EEPROM — это код операции 0x03, 16-битный адрес, затем холостые (dummy) байты, выдаваемые тактами, чтобы считать данные обратно. Единственная тонкость на этом DSPI: сигнал выбора кристалла (chip-select) должен оставаться активным на протяжении всей транзакции, поэтому кадры команды/адреса/холостой передаёшь встык с установленным битом продолжения, снимая CS только на последнем. Одно ожидание на каждый байт приводит к опустошению FIFO и снятию CS посреди передачи. Поэтому каждый байт данных — это свой аккуратный пакет из 4 кадров.

spi_ee_read — пакетное чтение M95640 по DSPI_B

#define DSPI_B     0xFFF94000u
#define DSPI_MCR   (*(volatile u32 *)(DSPI_B + 0x00u))
#define DSPI_CTAR0 (*(volatile u32 *)(DSPI_B + 0x0Cu))
#define DSPI_SR    (*(volatile u32 *)(DSPI_B + 0x2Cu))
#define DSPI_PUSHR (*(volatile u32 *)(DSPI_B + 0x34u))
#define DSPI_POPR  (*(volatile u32 *)(DSPI_B + 0x38u))

static void spi_ee_init(void)
{
    *(volatile u16 *)0xC3F9004Au = 0x0600u;   /* PCR5  CS0  out */
    *(volatile u16 *)0xC3F9004Cu = 0x0600u;   /* PCR6  SCK  out */
    *(volatile u16 *)0xC3F9004Eu = 0x0600u;   /* PCR7  SOUT out */
    *(volatile u16 *)0xC3F90050u = 0x0100u;   /* PCR8  SIN  in  */
    DSPI_MCR   = 0x80010C00u;   /* master, PCS0 idle-high, flush FIFOs, running */
    DSPI_CTAR0 = 0x38004448u;   /* 8-bit frame, mode0, slow baud (safe) */
}

/* READ (0x03): each byte is a 4-frame burst [cmd, addrHi, addrLo(CONT), dummy].
   The 4th RX byte is the data; CS stays low for the whole burst. */
static void spi_ee_read(u16 addr, u8 *dst, int len)
{
    int i;
    spi_ee_init();
    for (i = 0; i < len; i++) {
        u16 a = (u16)(addr + i);
        volatile u32 to = 0;
        DSPI_MCR   = 0x80010C00u;                      /* flush FIFOs */
        DSPI_SR    = 0xFFFF0000u;                       /* clear status */
        DSPI_PUSHR = 0x80010000u | 0x03u;              /* READ   (CONT) */
        DSPI_PUSHR = 0x80010000u | (u32)(a >> 8);      /* addr hi(CONT) */
        DSPI_PUSHR = 0x80010000u | (u32)(a & 0xFFu);   /* addr lo(CONT) */
        DSPI_PUSHR = 0x00010000u | 0xFFu;              /* dummy, drop CS */
        while (((DSPI_SR >> 4) & 0xFu) < 4u) if (++to > 200000u) break;
        (void)DSPI_POPR; (void)DSPI_POPR; (void)DSPI_POPR;  /* cmd/addr phases */
        dst[i] = (u8)(DSPI_POPR & 0xFFu);                   /* 4th frame = data */
    }
}

Запись EEPROM — только те байты, что реально изменились

Запись M95640 — хрестоматийный танец: WREN (разрешение записи, 0x06), затем WRITE (0x02) + адрес + данные, после чего опрашиваем бит WIP регистра состояния, пока не завершится цикл записи (~5 мс). Снова весь блок cmd+addr+data идёт одним пакетом с CS в низком уровне.

spi_ee_write_byte — WREN / WRITE / опрос WIP

static void spi_ee_write_byte(u16 addr, u8 val)
{
    volatile u32 to = 0;
    spi_ee_init();
    /* WREN */
    DSPI_SR = 0xFFFF0000u;
    DSPI_PUSHR = 0x00010000u | 0x06u;
    to = 0; while (((DSPI_SR >> 4) & 0xFu) < 1u) if (++to > 200000u) break;
    (void)DSPI_POPR;
    /* WRITE 0x02 + addrHi + addrLo + data (CS held, drop after data) */
    DSPI_MCR = 0x80010C00u; DSPI_SR = 0xFFFF0000u;
    DSPI_PUSHR = 0x80010000u | 0x02u;
    DSPI_PUSHR = 0x80010000u | (u32)(addr >> 8);
    DSPI_PUSHR = 0x80010000u | (u32)(addr & 0xFFu);
    DSPI_PUSHR = 0x00010000u | (u32)val;
    to = 0; while (((DSPI_SR >> 4) & 0xFu) < 4u) if (++to > 200000u) break;
    (void)DSPI_POPR; (void)DSPI_POPR; (void)DSPI_POPR; (void)DSPI_POPR;
    /* poll WIP until the write cycle completes (~5 ms) */
    to = 0; while ((spi_ee_rdsr() & 0x01u) && (++to < 100000u)) { }
}

Два сознательных решения в пользу безопасности на стороне управления этим:

  • Команда записи несёт защитный байт. Запись выполняется, только если в слове команды присутствует фиксированное защитное значение. Случайный или искажённый кадр не может непреднамеренно записать EEPROM — он просто отбрасывается.
  • Хост пишет разницу, а не целый блок. На стороне MS561 я держу последнее полное чтение EEPROM как снимок; запись затрагивает только те байты, что отличаются от него (и никогда — изменчивые рабочие поля), после чего считывает окно обратно и проверяет, что всё легло как надо. Если вы когда-нибудь «окирпичивали» модуль, переписав байт, который не собирались трогать, вы поймёте, зачем это.

В мастерской это обычно та самая сторона данных при ремонте с перескоком ремня. BMW регистрирует это как код неисправности (DTC) 0x482452 — «EPS steering angle sensor: belt jump detected» в нашем декодере — с аппаратным парным кодом 0x4822D9 («…steering angle invalid») и счётчиком перескоков ремня в DID 0xE33C. Несмотря на название, сам ремень обычно цел; просто сохранённые в модуле данные больше не соответствуют рейке, и перепрошивкой или кодированием это не сбросить. Привести данные в порядок позволяет именно обратная запись правильного содержимого EEPROM.

Запись EEPROM в MS561 подтверждена обратным считыванием

запись eeprom. Запись EEPROM и чтение сразу обратно для проверки: EEPROM write verified — 1151 byte(s). Затрагиваются только изменённые байты, и обратное считывание подтверждает каждый из них.

Запись флеш — калибровка L/R, с расчётом CRC прямо в устройстве

У 19EE левый и правый руль — не косметика: на праворульной рейке мотор усилителя крутится в другую сторону, поэтому флаг должен соответствовать машине. Всё сводится к двум байтам-флагам в block0, а block0 защищён CRC-32, которую проверяет прошивка. Поменяешь флаги, не исправив CRC, — и блок управления отвергнет калибровку.

Рецепт я вскрыл на стенде, сравнив подлинный дамп LEFT с подлинным дампом RIGHT:

  • block0[0xE9] = 0x00 для LEFT, 0x01 для RIGHT
  • block0[0x4E9] = 0xFF для LEFT, 0xFE для RIGHT
  • стандартная CRC-32 zlib/PKZIP (отражённый полином 0xEDB88320) по block0[0 .. 0x3E44), сохранённая в формате big-endian по адресу 0x3FCC

Переключение одной рейки затрагивает ровно эти шесть байтов — и больше ничего. Байтов-флагов два, а не один, потому что это избыточная пара: 0x4E9 содержит дополнение до единицы (one's-complement) от 0x0E9 (0x00/0xFF, 0x01/0xFE), ровно на 0x400 дальше, и начало block0 зеркально продублировано — как побитовое НЕ — по тому же смещению +0x400. Это та самая защитная схема хранения «значение плюс его инверсия», которую используют блоки управления, и оба байта попадают в область CRC-32 — так что корректное изменение означает согласованную установку пары и пересчёт CRC.

CRC в точности совпала с обоими эталонными дампами — именно так я и понял, что взял верный диапазон и верный алгоритм. Поэтому агент несёт собственную крошечную побитовую CRC-32 (без таблицы — чтобы агент оставался компактным) и пересчитывает контрольную сумму прямо в рейке после правки флагов, так что прошиваемый образ получается самосогласованным.

crc32_zlib — прямо в устройстве, без таблицы

/* CRC-32 (zlib/PKZIP: reflected poly 0xEDB88320, init/xorout 0xFFFFFFFF), bit-serial. */
static u32 crc32_zlib(const u8 *p, u32 n)
{
    u32 c = 0xFFFFFFFFu, i; int k;
    for (i = 0; i < n; i++) {
        c ^= p[i];
        for (k = 0; k < 8; k++) c = (c & 1u) ? ((c >> 1) ^ 0xEDB88320u) : (c >> 1);
    }
    return c ^ 0xFFFFFFFFu;
}

Само программирование — это контроллер C90FL: разблокировать блок, стереть его, затем перепрограммировать двойное слово за двойным словом, пропуская чистые (0xFF) двойные слова. Ничего экзотического, но каждый шаг — это «взвести, подать высокое напряжение, дождаться DONE, проверить PEG (program/erase good)», а ошибка здесь — это мёртвая рейка, так что стоит быть аккуратным и повторно заблокировать блок после.

flash_write_block0 / flash_set_position — стирание, программирование, CRC

#define CFLASH_BASE 0xC3F88000u
#define CF_MCR (*(volatile u32 *)(CFLASH_BASE + 0x00u))
#define CF_LML (*(volatile u32 *)(CFLASH_BASE + 0x04u))
#define CF_SLL (*(volatile u32 *)(CFLASH_BASE + 0x0Cu))
#define CF_LMS (*(volatile u32 *)(CFLASH_BASE + 0x10u))
#define MCR_PGM 0x10u
#define MCR_ERS 0x04u
#define MCR_EHV 0x01u
#define MCR_DONE 0x400u
#define MCR_PEG  0x200u
#define LML_PW 0xA1A11111u
#define SLL_PW 0xC3C33333u

static u8 blk[0x4000];
static int flash_write_block0(void)
{
    int i; volatile u32 to;
    if (!(CF_MCR & MCR_DONE)) return -1;
    CF_LML = LML_PW; CF_LML = 0x001303FEu;     /* unlock block0 */
    CF_SLL = SLL_PW; CF_SLL = 0x001303FEu;
    /* erase block0 */
    CF_MCR = MCR_ERS;
    CF_LMS = 0x00000001u;                      /* select block0 */
    *(volatile u32 *)0x0 = 0xFFFFFFFFu;        /* interlock write */
    CF_MCR = MCR_ERS | MCR_EHV;
    to = 0; while (!(CF_MCR & MCR_DONE)) if (++to > 40000000u) break;
    if (!(CF_MCR & MCR_PEG)) { CF_MCR = MCR_ERS; CF_MCR = 0u; CF_LMS = 0u; return -2; }
    CF_MCR = MCR_ERS; CF_MCR = 0u; CF_LMS = 0u;
    /* reprogram non-blank doublewords from the SRAM snapshot */
    for (i = 0; i < 0x4000; i += 8) {
        u32 w0 = ((u32)blk[i]   << 24) | ((u32)blk[i+1] << 16) | ((u32)blk[i+2] << 8) | blk[i+3];
        u32 w1 = ((u32)blk[i+4] << 24) | ((u32)blk[i+5] << 16) | ((u32)blk[i+6] << 8) | blk[i+7];
        if (w0 == 0xFFFFFFFFu && w1 == 0xFFFFFFFFu) continue;
        CF_MCR = MCR_PGM;
        *(volatile u32 *)(u32)i       = w0;
        *(volatile u32 *)(u32)(i + 4) = w1;
        CF_MCR = MCR_PGM | MCR_EHV;
        to = 0; while (!(CF_MCR & MCR_DONE)) if (++to > 4000000u) break;
        if (!(CF_MCR & MCR_PEG)) { /* re-lock and bail -> restore from backup */ return -3; }
        CF_MCR = MCR_PGM; CF_MCR = 0u;
    }
    CF_LML = LML_PW; CF_LML = 0x001303FFu;     /* re-lock */
    CF_SLL = SLL_PW; CF_SLL = 0x001303FFu;
    return 0;
}

/* snapshot block0, flip L/R flags, recompute CRC, commit */
static int flash_set_position(int right)
{
    int i; u32 crc;
    if (!(CF_MCR & MCR_DONE)) return -1;
    for (i = 0; i < 0x4000; i++) blk[i] = *(const volatile u8 *)(u32)i;
    if (right) { blk[0x0E9] = 0x01u; blk[0x4E9] = 0xFEu; }
    else       { blk[0x0E9] = 0x00u; blk[0x4E9] = 0xFFu; }
    crc = crc32_zlib(blk, 0x3E44u);
    blk[0x3FCC] = (u8)(crc >> 24); blk[0x3FCD] = (u8)(crc >> 16);   /* big-endian */
    blk[0x3FCE] = (u8)(crc >> 8);  blk[0x3FCF] = (u8)(crc);
    return flash_write_block0();
}

Стирание флеш — самое опасное, что может сделать агент, поэтому команда, запускающая его, спрятана за тройной защитой: выделенный код операции, контрольный байт и магическое слово — все должны совпасть в одном кадре, прежде чем будет вызвана flash_set_position. Один неверный бит где угодно — и запрос игнорируется. Ответ несёт отдельное эхо-контрольное значение (не адрес) и код возврата контроллера в хвосте, так что хост может понять, что этот кадр — подтверждение после записи, и проверить результат обратным считыванием.

Я переключил тестовую рейку LEFT→RIGHT→LEFT, выверил дампы в каждую сторону и оставил её в исходной конфигурации LEFT. ret=0, обратное считывание совпадает, обратимо.

Запись флеш в MS561 подтверждена — рулевое переведено в RIGHT

запись L/R. Переключение L/R во флеш, считанное обратно для подтверждения: Write verified: steering = RIGHT. Safe to switch off the power. Изменённая строка подсвечена в дампе, а CRC пересчитана прямо в рейке.

Цикл обработки команд целиком

Четыре команды — чтение флеш, чтение EEPROM, запись EEPROM, запись L/R — на каждую ответ идёт в TX-слоте с эхо-меткой. Точка входа записывает сигнатуру «я жив» (00 4C 8A A0 A1 A2 … BF) перед входом в цикл, так что я могу подтвердить, что мой код загружен и работает исключительно с шины, без подключённого JTAG.

agent_main — диспетчер

void agent_main(void)
{
    static u16 frame[64];
    static u8  buf[32];
    u16 cmd[20]; int n, i, ret; u32 addr;

    for (;;) {
        n = rx_poll(cmd, 20);
        if (n < 3) continue;

        if (/* READ flash */ cmd[0] == 0x0017u && (cmd[1] & 0xFF00u) == 0x6300u) {
            addr = ((u32)(cmd[1] & 0xFFu) << 16) | cmd[2];
            for (i = 0; i < 32; i++) buf[i] = *(const volatile u8 *)(addr + i);
            build_response(frame, buf);
            frame[18] = (u16)(addr & 0xFFFFu); frame[19] = REPLY_MAGIC;
            tx_send(frame, 62);
        }
        else if (/* READ eeprom */ cmd[0] == 0x0025u && (cmd[1] & 0xFF00u) == 0x6300u) {
            addr = ((u32)(cmd[1] & 0xFFu) << 16) | cmd[2];
            spi_ee_read((u16)addr, buf, 32);
            build_response(frame, buf);
            frame[18] = (u16)(addr & 0xFFFFu); frame[19] = REPLY_MAGIC;
            tx_send(frame, 62);
        }
        else if (/* WRITE eeprom (guarded) */ cmd[0] == 0x00B7u /* + guard check */) {
            /* ... write the diff bytes, read the window back, reply with read-back ... */
        }
        else if (/* WRITE L/R (triple-guarded, destructive) */ cmd[0] == 0x00B9u /* + guards */) {
            ret = flash_set_position((cmd[1] >> 8) & 0x1);
            for (i = 0; i < 30; i++) buf[i] = *(const volatile u8 *)(u32)i;
            buf[30] = (u8)(ret & 0xFF); buf[31] = (u8)((ret >> 8) & 0xFF);
            build_response(frame, buf);
            frame[18] = 0x5A5Au; frame[19] = REPLY_MAGIC;
            tx_send(frame, 62);
        }
    }
}

«Шифрование», вкратце — и почему секрет мне так и не понадобился

Память возвращается не в открытом виде; путь чтения в прошивке XOR-ит её с гаммой (keystream). Её часто принимают за стойкий шифр — это не так. После сбора известного открытого текста (стёртые области 0xFF плюс область кода, почти одинаковая у разных узлов) гамма оказывается линейной над GF(2) функцией от адреса — это LFSR/CRC по адресу, а не настоящий блочный шифр. Есть и статистический признак: старший бит (MSB) каждого 32-битного слова гаммы смещён — именно это и выдаёт дешёвый линейный генератор.

Поскольку она линейна, гамма полностью восстанавливается из известного открытого текста: решаешь небольшую систему на известном блоке, распространяешь на остальное по одному известному слову на блок — и можешь расшифровать/зашифровать любой узел, так и не извлекая ни seed, ни секретный ключ. Так что никакого секрета, который нужно скрывать от агента, нет — он его и не несёт. Он отдаёт сырые байты, а вычисления выполняет хост. Последовательность защищённого доступа я разобрал выше; единственное, что я придерживаю, — как формируется ключ, и это остаётся в моём ПО MS561.

Форму уязвимости я опишу — слабую обфускацию надо называть слабой, — но не приведу ни поузловые константы, ни рукопожатие доступа. Эта часть — не моё, чтобы её раздавать.

Замечание об оригинальности

Я слышал утверждение, что мои инструменты — «точные копии 1:1» инструментов одного вендора, так что одно замечание на этот счёт.

Всё на этой странице получено из реверс-инжиниринга прошивки в Ghidra: карты регистров, гонка с блокировкой, тайминги SPI, рецепт CRC из моих собственных дампов left/right, исходник агента выше. Метод здесь приведён полностью, с исходным кодом.

Сам протокол — мой собственный: эхо-ответ 0x4321, заголовок ответа 00 4C 8A, блокировка «крутись, пока CC не отдаст», тройная защита стирания. Если это дословно всплывёт в чужом бинарнике — делайте вывод сами, в какую сторону всё шло.

Merhaba. Приятного чтения. 👋

Результаты

Операция Транспорт Статус
Чтение флеш (блоки по 16 КБ, полный образ) FlexRay, без JTAG diff-0 относительно эталона, многократно проверено (~27 с / 16 КБ)
Чтение EEPROM (вся M95640) FlexRay, без JTAG побайтово точно, проверено (~14 с / 8 КБ)
Запись EEPROM (только изменённые байты) FlexRay, без JTAG обратное считывание — СОВПАДЕНИЕ, обратимо
Запись флеш (флаги L/R + CRC) FlexRay, без JTAG ret=0, проверено обратным считыванием, обратимо

Ни JTAG-пробника на узле, ни секрета, зашитого в агент, — лишь небольшая программа в SRAM рейки, отвечающая на запросы по FlexRay, и стенд, который теперь умеет читать и писать рейку 19EE от начала до конца.

Остальная часть агента — полный хостовый драйвер и набор команд — остаётся на моём стенде, но описанное выше было самым сложным.

Источники

  • NXP, MPC5643L Microcontroller Reference Manual — Flash Memory Array and Control (C90FL): регистры блокировки LML/SLL (смещения 0x04/0x0C) и их пароли для изменения блокировки, 0xA1A11111 и 0xC3C33333.
  • Сообщество NXP, программирование регистров блокировки C90FL на родственных кристаллах MPC5xxx: пример MPC5644A, FLASH-блок MPC5744P.

Благодарности

Особая благодарность нашим коллегам из Бразилии и Мексики за помощь в разработке этого решения. Мы очень ценим вашу поддержку и сотрудничество!