experiments
March 20

Бобер, который смог: бекпорт Golang на Windows 7

После того как нам удалось «портировать назад» Node.js, занялись поиском следующей цели, которой после недолгих раздумий стал компилятор Go.

Полгода жестоких экспериментов и удивительный результат.

Готовую сборку Golang 1.24 для Windows 7 можно забрать в нашем Телеграм канале, тут.

Вводная

В отличие от истории с Node.js, бекпорт последней версии Golang на Windows 7 — чисто исследовательский проект, который был реализован целиком ради демонстрации наших талантов.

Язык Golang является слишком молодым и далеким от мира Windows, несмотря на широкие возможности.

Поэтому просто не успело образоваться сколь-нибудь серьезное количество проектов на Golang и под Windows, нуждающихся в подобном бекпорте.

Тем более в пределах РФ.

Особенности

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

К сожалению начиная с версии 1.5, для сборки компилятора Go необходимо иметь на машине работающий компилятор Go предыдущей версии:

 The minimum version of Go required depends on the target version of Go:
    Go <= 1.4: a C toolchain.
    1.5 <= Go <= 1.19: a Go 1.4 compiler.
    1.20 <= Go <= 1.21: a Go 1.17 compiler.
    1.22 <= Go <= 1.23: a Go 1.20 compiler.
    Going forward, Go version 1.N will require a Go 1.M compiler, 
    where M is N-2 rounded down to an even number. 
    Example: Go 1.24 and 1.25 require Go 1.22. 

Так что для сборки последней на момент написания статьи версии 1.24, необходимо иметь работающий компилятор версии 1.22, для сборки которой надо иметь версию 1.20.

Поддержка Windows 7 в Golang закончилась не так уж давно — с релизом версии 1.20 в феврале 2023 года:

Go 1.20 was the last release supporting Windows 2008: https://go.dev/doc/go1.20#windows

Но даже за столь небольшой для такого рода проектов временной отрезок, разработчики успели наделать серьезных внутренних изменений (особенно в последней версии), заставивших искать концы с определенным напряжением.

Если проще:

мы слегка за#бались.

Можно было начать проект с версии 1.20, бинарные сборки которой для Windows все еще доступны для скачивания, но мы решили пойти дальше и последовательно собрали всю цепочку компиляторов, начиная с версии 1.4.

Если вам это не актуально, то начальный этап можно пропустить.

Подготовка

Как бы это не было удивительно, но сборка Golang с помощью стандартного компилятора Microsoft не поддерживается и требует MinGW, причем еще и вместе с binutils (!).

И все это на венде да.

Чтобы не заморачиваться сборкой еще и этого блядского цирка из исходников, рекомендую использовать готовый билд, например отсюда.

Папка bin из архива должна быть в переменной PATH:

set PATH=c:\work\mingw64\bin;%PATH%

Архивы с исходным кодом Golang брались с официального сайта, в качестве альтернативного варианта можно использовать официальный репозиторий Google, вот так выглядит релизная ветка для версии 1.22:

Во всех версиях сборка осуществляется с помощью скриптов в каталоге src:

cd src
make

Начальный этап

Начнем наши приключения с версии 1.4 — последней версии Golang, собираемой с помощью С-компилятора.

Скачиваем архив с исходным кодом, распаковываем и запускаем сборку:

Если сборка завершится удачно, в каталоге bin появится рабочий компилятор Go версии 1.4.

Следующим шагом задаем переменную окружения, указывающую на только что собранную версию 1.4, которая будет использована для сборки уже версии 1.19:

set GOROOT_BOOTSTRAP=c:\work\backport-go\1.4

Запускаем сборку аналогичным образом:

Если сборка завершится успешно, в каталоге bin появится работающий компилятор Go уже версии 1.19:

Точно таким же образом повторяем для сборки версии 1.20:

Таким образом получается последняя версия Golang, работающая в Windows 7 «из коробки» и собранная целиком из исходников.

Напоминаю, что можно было столь сильно не заморачиваться и просто скачать готовую релизную версию 1.20, которая будет использоваться для последующих сборок.

Портирование назад

К сожалению на версии 1.20 сказка заканчивается и начинается традиционная жесть:

Да, глаза вас не подводят — ошибка действительно вроде бы в ассемблерном коде:

..
docall:
	// Call stdcall function.
	CALL	AX

	ADDQ	$(const_maxArgs*8), SP

	// Return result.
	MOVQ	0(SP), CX
	MOVQ	8(SP), SP
	MOVQ	AX, libcall_r1(CX)
	// Floating point return values are returned in XMM0. Setting r2 to this
	// value in case this call returned a floating point value. For details,
	// see https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention
	MOVQ    X0, libcall_r2(CX)

	// GetLastError().
	MOVQ	0x30(GS), DI
	MOVL	0x68(DI), AX
	MOVQ	AX, libcall_err(CX)

	RET
..

Код выше нифига не генерированный, он создан и поддерживается полностью вручную.

Так что для работы над компилятором Golang внезапно надо знать ассемблер, причем на хорошем уровне, поскольку там куча сложных оптимизаций.

75 строка на которую указывает трассировка (трассировка в ассемблерном коде!) это на самом деле подготовка вызова WinAPI функции:

ADDQ	$(const_maxArgs*8), SP

Поэтому ошибка является наведенной.

Наведенная ошибка в коде на ассемблере, который вызывает функцию WinAPI

А вы говорите дурка и санитары нейросети нас всех заменят, ага.

Кровавые патчи

Стоило больших усилий выяснить реальную природу происходящего и найти два ключевых патча (один и два), изменения в которых необходимо откатить, чтобы Golang снова заработал в «Windows 7 and older» системах.

Было нелегко, но слава богу обошлось без разбитых об стену клавиатур и мониторов.

Прежде чем приступать к «кровавому патчингу», вам стоит знать что накатить данные патчи с помощью cherry pick (и тем более автоматически) невозможно, поскольку они создавались для одной конкретной версии исходников Golang, а применять их придется несколько раз для разных версий.

Патч первый

Из описания коммита, следует что речь снова идет про «хотели сделать как лучше»:

RtlGenRandom is a semi-undocumented API, also known as
SystemFunction036, which we use to generate random data on Windows.
It's definition, in cryptbase.dll, is an opaque wrapper for the
documented API ProcessPrng. Instead of using RtlGenRandom, switch to
using ProcessPrng, since the former is simply a wrapper for the latter,
there should be no practical change on the user side, other than a minor
change in the DLLs we load.

Все красиво, логично и замечательно, кроме факта что в Windows 7 нет поддержки функции ProcessPrng.

А упомянутая SystemFunction036 внезапно есть.

Ошибка частая и страдают все, кто еще по какой-то причине использует устаревшие версии Windows.

Пропускаем две первых правки, поскольку они не содержат правок непосредственно кода, смотрим файл rand_windows.go:

Напоминаю для зумеров, что поскольку мы делаем портирование назад — нужная нам логика находится слева и помечена красным, а ненужная (она же текущая) — справа.

Результат должен выглядеть так:

// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Windows cryptographically secure pseudorandom number
// generator.

package rand

import (
	"internal/syscall/windows"
)

func init() { Reader = &rngReader{} }

type rngReader struct{}

func (r *rngReader) Read(b []byte) (n int, err error) {
	// RtlGenRandom only returns 1<<32-1 bytes at a time. We only read at
	// most 1<<31-1 bytes at a time so that  this works the same on 32-bit
	// and 64-bit systems.
	if err := batched(windows.RtlGenRandom, 1<<31-1)(b); err != nil {
		return 0, err
	}
	return len(b), nil
}

В следующем файле zsyscall_windows.go все несколько сложнее:

Несмотря на цветовую подсветку из всего блока нужно лишь закоментировать строчку:

modbcryptprimitives = syscall.NewLazyDLL(sysdll.Add("bcryptprimitives.dll"))

и заменить описание метода procProcessPrng, строка:

procProcessPrng = modbcryptprimitives.NewProc("ProcessPrng")

меняется на:

procSystemFunction036 = modadvapi32.NewProc("SystemFunction036")

Но это еще не конец, чуть ниже в этом файле есть еще правки:

Нужно восстановить оригинальный метод, непосредственно вызывающий syscall:

func RtlGenRandom(buf []byte) (err error) {
	var _p0 *byte
	if len(buf) > 0 {
		_p0 = &buf[0]
	}
	r1, _, e1 := syscall.Syscall(procSystemFunction036.Addr(), 2, uintptr(unsafe.Pointer(_p0)), uintptr(len(buf)), 0)
	if r1 == 0 {
		err = errnoErr(e1)
	}
	return
}

Наконец последний файл, в который необходимо внести изменения это os_windows.go.

Первым делом заменяем вызов все того же ProcessPrng:

Вместо:

_ProcessPrng stdFunction

надо вставить:

_RtlGenRandom stdFunction

Ниже в этом же файле необходимо изменить такую своеобразную ссылку на название вызываемой библиотеки:

Вместо:

bcryptprimitivesdll = [...]uint16{'b', 'c', 'r', 'y', 'p', 't', 'p', 'r', 'i', 'm', 'i', 't', 'i', 'v', 'e', 's', '.', 'd', 'l', 'l', 0}

должно получиться:

advapi32dll = [...]uint16{'a', 'd', 'v', 'a', 'p', 'i', '3', '2', '.', 'd', 'l', 'l', 0}

Еще немного ниже по коду вас ждет еще одна правка, в методе loadOptionalSyscalls:

Тут всего лишь простая замена блока, текущую версию (справа) необходимо заменить на предыдущую версию (слева):

a32 := windowsLoadSystemLib(advapi32dll[:])
if a32 == 0 {
		throw("advapi32.dll not found")
}
_RtlGenRandom = windowsFindfunc(a32, []byte("SystemFunction036\000"))

Наконец последняя правка в этом файле:

Необходимо заменить готовый вызов метода, отвечающего за генерацию шума (случайных данных) в системной функции getRandomData.

Тут стоит уточнить, что в рамках одной мажорной версии 1.22 эта функция была переименована в readRandom.

Итоговый код для последней версии ветки 1.22:

//go:nosplit
func readRandom(r []byte) int {
	n := 0
	if stdcall2(_RtlGenRandom, uintptr(unsafe.Pointer(&r[0])), uintptr(len(r)))&0xff != 0 {
		n = len(r)
	}	
	return n
}

Если все манипуляции были выполнены правильно — сборка Golang версии 1.22 пройдет успешно.

Но правильно работать не будет.

Тем не менее, даже этой кривой версии хватит для сборки следующей версии 1.25

Если вы не собираетесь использовать версию 1.22 для работы, а лишь в качестве промежуточного компилятора — второй патч можно пропустить.

Патч второй

Патч с непростой судьбой — добавленный в 2021м году он вскоре был удален из-за полного отказа от поддержки старых версий Windows:

On Windows 7 (and below), console handles are not real kernel handles
but are rather userspace objects, with information passed via special
bits in the handle itself. That means they can't be passed in
PROC_THREAD_ATTRIBUTE_HANDLE_LIST, even though they can be inherited.

Суть его в том, что в старых версиях Windows есть определенная магия с передачей дополнительных атрибутов у console handle, что мешает правильной работе.

Без патча попытка собрать Golang 1.24 порождает вот такую ошибку:

Она же появляется при попытке запуска тестов в версии 1.22:

Первый файл для правок exec_windows.go и правки в нем достаточно сложные:

Тут необходимо восстановить блок, отвечающий за определение версии Windows:

var maj, min, build uint32
rtlGetNtVersionNumbers(&maj, &min, &build)
isWin7 := maj < 6 || (maj == 6 && min <= 1)
// NT kernel handles are divisible by 4, with the bottom 3 bits left as
// a tag. The fully set tag correlates with the types of handles we're
// concerned about here.  Except, the kernel will interpret some
// special handle values, like -1, -2, and so forth, so kernelbase.dll
// checks to see that those bottom three bits are checked, but that top
// bit is not checked.
isLegacyWin7ConsoleHandle := func(handle Handle) bool { return isWin7 && handle&0x10000003 == 3 }

и вставить логику его использования ниже, вместо:

if attr.Files[i] > 0 {
			err := DuplicateHandle(p, Handle(attr.Files[i]), parentProcess, &fd[i], 0, true, DUPLICATE_SAME_ACCESS)

должен быть блок:

if attr.Files[i] > 0 {
			destinationProcessHandle := parentProcess
			// On Windows 7, console handles aren't real handles, and can only be duplicated
			// into the current process, not a parent one, which amounts to the same thing.
			if parentProcess != p && isLegacyWin7ConsoleHandle(Handle(attr.Files[i])) {
				destinationProcessHandle = p
			}
			err := DuplicateHandle(p, Handle(attr.Files[i]), destinationProcessHandle, &fd[i], 0, true, DUPLICATE_SAME_ACCESS)

Следующая правка в этом же файле следует аналогичной логике:

Добавляется метод определения версии Windows:

// On Windows 7, console handles aren't real handles, so don't pass them
// through to PROC_THREAD_ATTRIBUTE_HANDLE_LIST.
for i := range fd {
		if isLegacyWin7ConsoleHandle(fd[i]) {
			fd[i] = 0
		}
}
// The presence of a NULL handle in the list is enough to cause 
// PROC_THREAD_ATTRIBUTE_HANDLE_LIST
// to treat the entire list as empty, so remove NULL handles.
j := 0
for i := range fd {
		if fd[i] != 0 {
			fd[j] = fd[i]
			j++
		}
}
fd = fd[:j]

который затем применяется, блок:

// Do not accidentally inherit more than these handles.
err = updateProcThreadAttribute(si.ProcThreadAttributeList, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&fd[0]), uintptr(len(fd))*unsafe.Sizeof(fd[0]), nil, nil)
if err != nil {
		return 0, 0, err

должен быть заменен на:

// Do not accidentally inherit more than these handles.
if len(fd) > 0 {
		err = updateProcThreadAttribute(si.ProcThreadAttributeList, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&fd[0]), uintptr(len(fd))*unsafe.Sizeof(fd[0]), nil, nil)
		if err != nil {
			return 0, 0, err
		}

Чуть ниже в этом же файле находится следующий блок для исправления, напоминаю что интересующая нас правка в этот раз справа, слева — текущий вариант:

Суть правки — применение фильтрации при передаче атрибутов процесса, для учета специфики старых версий Windows.

Следующая остановка — файл zsyscall_windows.go, обратите внимание что это другой файл с совпадающим названием (из первого патча), но в другом каталоге:

В этот раз необходимо добавить новую библиотеку:

modntdll    = NewLazyDLL(sysdll.Add("ntdll.dll"))

и новую вызываемую функцию:

procRtlGetNtVersionNumbers = modntdll.NewProc("RtlGetNtVersionNumbers")

Чуть ниже добавляется и функция-обертка, отвечающая за вызов syscall:

После внесения этих правок, в версии 1.22 начинают распускаться цветы запускаться тесты:

Кровавый патчинг для Golang 1.24

Все описанное выше хоть и выглядит сложно, но для профессионала является не более чем рутиной.

Не стоило возиться и писать эту статью, если бы не версия 1.24.

Потому что с этого места начинается настоящая жесть, треш и угар — с версии 1.24 изменилась внутренняя структура каталогов и часть исходных файлов, в которых предстояло делать правки была перемещена.

А еще поменялась внутренняя логика.

И часть логики описанных патчей была внесена, но с изменениями.

Так что праздник и безудержное веселье начинаются.

Патч первый

Файл rand_windows.go с которого мы начинали правки, был перемещен в src/crypto/internal/sysrand/rand_windows.go а его содержимое теперь выглядит вот так:

// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package sysrand

import "internal/syscall/windows"

func read(b []byte) error {
	return windows.ProcessPrng(b)
}

Что сильно отличается от оригинальной версии.

Не буду утомлять читателей детальным описанием процесса поиска решения и сразу покажу конечный результат:

// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package sysrand

import "internal/syscall/windows"

// batched returns a function that calls f to populate a []byte by chunking it
// into subslices of, at most, readMax bytes.
func batched(f func([]byte) error, readMax int) func([]byte) error {
	return func(out []byte) error {
		for len(out) > 0 {
			read := len(out)
			if read > readMax {
				read = readMax
			}
			if err := f(out[:read]); err != nil {
				return err
			}
			out = out[read:]
		}
		return nil
	}
}

func read(b []byte) error {
	return batched(windows.RtlGenRandom, 1<<31-1)(b)		
}

Как видите прямо сюда была добавлена функция batched, которая была удалена из версии 1.24.

Следующие правки в файле zsyscall_windows.go полностью совпадают с описанным выше для версии 1.22, так что нет смысла повторяться.

Правки в файле os_windows.go также совпадают с правками для версии 1.22, за исключением измененной функции getRandomData, которая превратилась в readRandom и стала возвращать int.

Обновленный код для версии 1.22 приведен выше и работает для 1.24.

Патч второй

Для версии 1.24 является обязательным, поскольку без него сборка не проходит совсем.

Ключевая проблема заключается в том, что логика этого патча частично уже присутствует в коде, так что придется изгаляться.

Основная проблема - правки в файле exec_windows.go, первую их часть в виде блока:

var maj, min, build uint32
rtlGetNtVersionNumbers(&maj, &min, &build)
isWin7 := maj < 6 || (maj == 6 && min <= 1)
// NT kernel handles are divisible by 4, with the bottom 3 bits left as
// a tag. The fully set tag correlates with the types of handles we're
// concerned about here.  Except, the kernel will interpret some
// special handle values, like -1, -2, and so forth, so kernelbase.dll
// checks to see that those bottom three bits are checked, but that top
// bit is not checked.
isLegacyWin7ConsoleHandle := func(handle Handle) bool { return isWin7 && handle&0x10000003 == 3 }

вставляете как есть, следущий блок необходимо сводить вручную, готовый результат выглядит так:

fd := make([]Handle, len(attr.Files))
for i := range attr.Files {
		if attr.Files[i] > 0 {
		
			destinationProcessHandle := parentProcess
			// On Windows 7, console handles aren't real handles, and can only be duplicated
			// into the current process, not a parent one, which amounts to the same thing.
			if parentProcess != p && isLegacyWin7ConsoleHandle(Handle(attr.Files[i])) {
				destinationProcessHandle = p
			}
			err := DuplicateHandle(p, Handle(attr.Files[i]), destinationProcessHandle, &fd[i], 0, true, DUPLICATE_SAME_ACCESS)
		
			if err != nil {
				return 0, 0, err
			}
			defer DuplicateHandle(parentProcess, fd[i], 0, nil, 0, false, DUPLICATE_CLOSE_SOURCE)
		}
}

Следующий блок также необходимо сводить вручную, поскольку логика частично реализована.

Готовый блок выглядит следующим образом:

	// On Windows 7, console handles aren't real handles, so don't pass them
	// through to PROC_THREAD_ATTRIBUTE_HANDLE_LIST.
	for i := range fd {
		if isLegacyWin7ConsoleHandle(fd[i]) {
			fd[i] = 0
		}
	}
	// The presence of a NULL handle in the list is enough to cause PROC_THREAD_ATTRIBUTE_HANDLE_LIST
	// to treat the entire list as empty, so remove NULL handles.
	j := 0
	for i := range fd {
		if fd[i] != 0 {
			fd[j] = fd[i]
			j++
		}
	}
	fd = fd[:j]

	
	willInheritHandles := len(fd) > 0 && !sys.NoInheritHandles

	// Do not accidentally inherit more than these handles.
	if willInheritHandles {
		err = updateProcThreadAttribute(si.ProcThreadAttributeList, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&fd[0]), uintptr(len(fd))*unsafe.Sizeof(fd[0]), nil, nil)
		if err != nil {
			return 0, 0, err
		}
	}

Наконец последний блок, нуждающийся в ручном сведении из-за того что вызов метода createEnvBlock был вынесен выше.

Результат после сведения:

pi := new(ProcessInformation)
flags := sys.CreationFlags | CREATE_UNICODE_ENVIRONMENT | _EXTENDED_STARTUPINFO_PRESENT
if sys.Token != 0 {
		err = CreateProcessAsUser(sys.Token, argv0p, argvp, sys.ProcessAttributes, sys.ThreadAttributes, willInheritHandles, flags, &envBlock[0], dirp, &si.StartupInfo, pi)
} else {
		err = CreateProcess(argv0p, argvp, sys.ProcessAttributes, sys.ThreadAttributes, willInheritHandles, flags, &envBlock[0], dirp, &si.StartupInfo, pi)
}
if err != nil {
		return 0, 0, err
}

Правки для zsyscall_windows.go полностью совпадают с версией 1.22, которые описаны выше — просто последовательно внесите такие же изменения.

Тестовый проект

Чтобы проверить работоспособность получившегося чуда, был взят один из самых жирных boilerplate (шаблон проекта) для Golang и запущен.

Его в работе вы можете наблюдать на начальном скриншоте.

Забираем исходники:

git clone https://github.com/codoworks/go-boilerplate.git

Запускаем сборку:

cd go-boilerplate
go get
go run . db migrate
go run . db seed

На этой стадии вылезет ошибка:

требующая сборки Golang с включенной опцией:

CGO_ENABLED=1

Устанавливаем эту переменную окружения и заново запускаем скрипт make.

Запуск приложения:

go run . start

Так выглядит запущенный сервер в работе:

Эпилог

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

У нас нет проекта на Golang, требующего подобного бекпорта, поэтому мы не можем сказать ничего вразумительного о качестве созданного бекпорта и возможных проблемах эксплуатации.

Тесты падают, не все — примерно 10%, но этого достаточно чтобы оказывать существенное влияние на работоспособность сборки в боевых условиях.

Имейте это ввиду прежде чем пытаться эксплуатировать.