From a741385847f815b25c480a1c2e17dbd349694696 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:20:23 -0800 Subject: [PATCH 1/4] Add a static-PIE entry point and converter for Linux tsc We are required to ship position-independent executables. Go's internal linker can build a PIE without cgo on several Linux architectures, but the result names the glibc dynamic loader as its interpreter, so it no longer starts on musl systems like Alpine, in distroless images, or on NixOS, all of which run today's static binaries fine. Go-linked PIEs only need R_*_RELATIVE relocations and set up their own TLS, so the loader has very little to do. Link a small entry point into tsc that does that work itself, and add a tool that removes PT_INTERP and points the ELF entry at it, producing a static-PIE that runs on any Linux system. --- tools/cmd/staticpie/main.go | 218 +++++++++++++++++++ tsc/cmd/tsc/main.go | 1 + tsc/internal/staticpie/entry.go | 25 +++ tsc/internal/staticpie/entry_linux_amd64.s | 140 ++++++++++++ tsc/internal/staticpie/entry_linux_arm64.s | 123 +++++++++++ tsc/internal/staticpie/entry_linux_loong64.s | 135 ++++++++++++ tsc/internal/staticpie/entry_linux_ppc64le.s | 150 +++++++++++++ tsc/internal/staticpie/entry_linux_s390x.s | 127 +++++++++++ tsc/internal/staticpie/staticpie.go | 18 ++ tsc/internal/staticpie/staticpie.h | 24 ++ 10 files changed, 961 insertions(+) create mode 100644 tools/cmd/staticpie/main.go create mode 100644 tsc/internal/staticpie/entry.go create mode 100644 tsc/internal/staticpie/entry_linux_amd64.s create mode 100644 tsc/internal/staticpie/entry_linux_arm64.s create mode 100644 tsc/internal/staticpie/entry_linux_loong64.s create mode 100644 tsc/internal/staticpie/entry_linux_ppc64le.s create mode 100644 tsc/internal/staticpie/entry_linux_s390x.s create mode 100644 tsc/internal/staticpie/staticpie.go create mode 100644 tsc/internal/staticpie/staticpie.h diff --git a/tools/cmd/staticpie/main.go b/tools/cmd/staticpie/main.go new file mode 100644 index 0000000000000..d903db8bea59c --- /dev/null +++ b/tools/cmd/staticpie/main.go @@ -0,0 +1,218 @@ +// Command staticpie converts a Linux position-independent executable built by +// Go's internal linker into a static-PIE that runs without an ELF interpreter. +// +// Go can only link PIEs that name the system's dynamic loader in PT_INTERP, +// which ties them to glibc-based systems. The binary must link in +// tsc/internal/staticpie, whose entry function applies the relocations that +// the loader would otherwise apply. This command verifies that the binary +// needs nothing else from a loader, then removes PT_INTERP and makes that +// function the ELF entry point. +package main + +import ( + "bytes" + "debug/elf" + "debug/gosym" + "encoding/binary" + "errors" + "fmt" + "io" + "os" +) + +const entrySymbol = "github.com/microsoft/TypeScript/tsc/internal/staticpie.entry" + +// relativeRelocs maps each supported machine to its R_*_RELATIVE type, the +// only relocation the entry function knows how to apply. +var relativeRelocs = map[elf.Machine]uint64{ + elf.EM_X86_64: uint64(elf.R_X86_64_RELATIVE), + elf.EM_AARCH64: uint64(elf.R_AARCH64_RELATIVE), + elf.EM_LOONGARCH: uint64(elf.R_LARCH_RELATIVE), + elf.EM_PPC64: uint64(elf.R_PPC64_RELATIVE), + elf.EM_S390: uint64(elf.R_390_RELATIVE), +} + +const ( + ehdrPhoff = 32 + ehdrEntry = 24 + ehdrPhentsize = 54 + phdrSize = 56 + relaSize = 24 +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: staticpie ") + os.Exit(2) + } + if err := run(os.Args[1]); err != nil { + fmt.Fprintf(os.Stderr, "staticpie: %s: %v\n", os.Args[1], err) + os.Exit(1) + } +} + +func run(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := convert(data); err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + return os.WriteFile(path, data, info.Mode()) +} + +// convert rewrites data, an ELF executable, in place. +func convert(data []byte) error { + f, err := elf.NewFile(bytes.NewReader(data)) + if err != nil { + return err + } + defer f.Close() + + if f.Class != elf.ELFCLASS64 { + return errors.New("not a 64-bit ELF file") + } + if f.Type != elf.ET_DYN { + return fmt.Errorf("type is %v, not ET_DYN; was it built with -buildmode=pie?", f.Type) + } + relative, ok := relativeRelocs[f.Machine] + if !ok || f.Machine == elf.EM_PPC64 && f.ByteOrder != binary.LittleEndian { + return fmt.Errorf("unsupported machine %v (%v)", f.Machine, f.ByteOrder) + } + bo := f.ByteOrder + if phentsize := bo.Uint16(data[ehdrPhentsize:]); phentsize != phdrSize { + return fmt.Errorf("unexpected program header size %d", phentsize) + } + + interp := -1 + var dynamic *elf.Prog + hasPhdr := false + for i, p := range f.Progs { + switch p.Type { + case elf.PT_INTERP: + if interp >= 0 { + return errors.New("multiple PT_INTERP headers") + } + interp = i + case elf.PT_DYNAMIC: + dynamic = p + case elf.PT_PHDR: + hasPhdr = true + } + } + if interp < 0 { + return errors.New("no PT_INTERP; is it already static?") + } + if dynamic == nil || !hasPhdr { + return errors.New("missing PT_DYNAMIC or PT_PHDR") + } + + rela, err := checkDynamic(f, dynamic) + if err != nil { + return err + } + for off := 0; off < len(rela); off += relaSize { + // Comparing all of r_info also checks that the symbol index is zero. + if info := bo.Uint64(rela[off+8:]); info != relative { + return fmt.Errorf("unsupported relocation with r_info %#x", info) + } + } + + entry, err := findEntry(f) + if err != nil { + return err + } + + interpHdr := bo.Uint64(data[ehdrPhoff:]) + uint64(interp)*phdrSize + bo.PutUint32(data[interpHdr:], uint32(elf.PT_NULL)) + bo.PutUint64(data[ehdrEntry:], entry) + return nil +} + +// checkDynamic verifies that the dynamic section asks for nothing beyond +// RELATIVE relocations, and returns the contents of DT_RELA. +func checkDynamic(f *elf.File, dynamic *elf.Prog) ([]byte, error) { + dyn, err := io.ReadAll(dynamic.Open()) + if err != nil { + return nil, err + } + bo := f.ByteOrder + var relaAddr, relaSz uint64 + bindNow := false +loop: + for off := 0; off+16 <= len(dyn); off += 16 { + tag := elf.DynTag(bo.Uint64(dyn[off:])) + val := bo.Uint64(dyn[off+8:]) + switch tag { + case elf.DT_NULL: + break loop + case elf.DT_RELA: + relaAddr = val + case elf.DT_RELASZ: + relaSz = val + case elf.DT_RELAENT: + if val != relaSize { + return nil, fmt.Errorf("unexpected DT_RELAENT %d", val) + } + case elf.DT_FLAGS: + bindNow = bindNow || elf.DynFlag(val)&elf.DF_BIND_NOW != 0 + case elf.DT_FLAGS_1: + bindNow = bindNow || elf.DynFlag1(val)&elf.DF_1_NOW != 0 + case elf.DT_NEEDED, elf.DT_REL, elf.DynTag(36) /* DT_RELR */, elf.DT_JMPREL, elf.DT_TEXTREL, + elf.DT_INIT, elf.DT_INIT_ARRAY, elf.DT_PREINIT_ARRAY: + return nil, fmt.Errorf("has %v, which requires a dynamic loader", tag) + } + } + // The entry function applies all relocations up front, so the binary + // is effectively BIND_NOW; requiring the flag keeps it advertised. + if !bindNow { + return nil, errors.New("not linked with -bindnow") + } + if relaSz%relaSize != 0 { + return nil, fmt.Errorf("DT_RELASZ %d is not a multiple of %d", relaSz, relaSize) + } + if relaSz == 0 { + return nil, nil + } + rela := make([]byte, relaSz) + for _, p := range f.Progs { + if p.Type == elf.PT_LOAD && p.Vaddr <= relaAddr && relaAddr+relaSz <= p.Vaddr+p.Filesz { + if _, err := p.ReadAt(rela, int64(relaAddr-p.Vaddr)); err != nil { + return nil, err + } + return rela, nil + } + } + return nil, errors.New("DT_RELA is not within a loaded segment") +} + +// findEntry looks up the staticpie entry function in the Go symbol table, +// which is kept even in binaries stripped with -s. +func findEntry(f *elf.File) (uint64, error) { + pclntab := f.Section(".gopclntab") + text := f.Section(".text") + if pclntab == nil || text == nil { + return 0, errors.New("missing .gopclntab or .text") + } + pcln, err := pclntab.Data() + if err != nil { + return 0, err + } + table, err := gosym.NewTable(nil, gosym.NewLineTable(pcln, text.Addr)) + if err != nil { + return 0, err + } + fn := table.LookupFunc(entrySymbol) + if fn == nil { + return 0, fmt.Errorf("%s not found; is the staticpie package linked in?", entrySymbol) + } + if fn.Entry < text.Addr || fn.Entry >= text.Addr+text.Size { + return 0, fmt.Errorf("%s at %#x is outside .text", entrySymbol, fn.Entry) + } + return fn.Entry, nil +} diff --git a/tsc/cmd/tsc/main.go b/tsc/cmd/tsc/main.go index 899113dc19fc9..319e70859716d 100644 --- a/tsc/cmd/tsc/main.go +++ b/tsc/cmd/tsc/main.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/execute" "github.com/microsoft/TypeScript/tsc/internal/osutil" + _ "github.com/microsoft/TypeScript/tsc/internal/staticpie" ) func main() { diff --git a/tsc/internal/staticpie/entry.go b/tsc/internal/staticpie/entry.go new file mode 100644 index 0000000000000..ed0dd7150e345 --- /dev/null +++ b/tsc/internal/staticpie/entry.go @@ -0,0 +1,25 @@ +//go:build linux && (amd64 || arm64 || loong64 || ppc64le || s390x) + +package staticpie + +// entry is the replacement ELF entry point, implemented in assembly. +// +// It runs before any relocations are applied, so it may only use +// PC-relative addressing. It: +// +// 1. finds the auxiliary vector above argv and envp on the initial stack; +// 2. uses AT_PHDR and the PT_PHDR program header to compute the load bias; +// 3. applies every DT_RELA relocation, failing on anything other than +// R_*_RELATIVE; +// 4. makes the page-aligned part of PT_GNU_RELRO read-only, as ld.so does; +// 5. jumps to the runtime's _rt0__linux with the stack untouched. +// +// It exits with status 127 if anything is unexpected. +func entry() + +// entryAddr returns the address of entry. Calling it keeps entry in the +// binary without referencing it from Go, which would make the linker emit +// an ABI wrapper under the same name. +func entryAddr() uintptr + +var _ = entryAddr() diff --git a/tsc/internal/staticpie/entry_linux_amd64.s b/tsc/internal/staticpie/entry_linux_amd64.s new file mode 100644 index 0000000000000..66d3feb39bc83 --- /dev/null +++ b/tsc/internal/staticpie/entry_linux_amd64.s @@ -0,0 +1,140 @@ +#include "textflag.h" +#include "staticpie.h" + +#define R_X86_64_RELATIVE 8 +#define SYS_mprotect 10 +#define SYS_exit_group 231 + +// R15 is avoided because the assembler clobbers it in -shared mode. +TEXT ·entry(SB),NOSPLIT|NOFRAME,$0 + // Skip argc, argv, and envp to find auxv. + MOVQ 0(SP), AX + LEAQ 16(SP)(AX*8), R8 +envloop: + MOVQ 0(R8), AX + ADDQ $8, R8 + TESTQ AX, AX + JNZ envloop + + // R9 = AT_PHDR, R10 = AT_PHNUM, R11 = AT_PAGESZ + XORQ R9, R9 + XORQ R10, R10 + XORQ R11, R11 +auxloop: + MOVQ 0(R8), AX + MOVQ 8(R8), BX + ADDQ $16, R8 + TESTQ AX, AX + JZ auxdone + CMPQ AX, $AT_PHDR + CMOVQEQ BX, R9 + CMPQ AX, $AT_PHNUM + CMOVQEQ BX, R10 + CMPQ AX, $AT_PAGESZ + CMOVQEQ BX, R11 + JMP auxloop +auxdone: + TESTQ R9, R9 + JZ fail + TESTQ R10, R10 + JZ fail + TESTQ R11, R11 + JZ fail + + // R12 = load bias, BP = PT_PHDR seen, R13 = PT_DYNAMIC vaddr, + // R14 = PT_GNU_RELRO vaddr, CX = PT_GNU_RELRO memsz + MOVQ R9, SI + MOVQ R10, DI + XORQ BP, BP + XORQ R13, R13 + XORQ R14, R14 + XORQ CX, CX +phloop: + TESTQ DI, DI + JZ phdone + MOVL 0(SI), AX + CMPL AX, $PT_PHDR + JNE notphdr + MOVQ R9, R12 + SUBQ P_VADDR(SI), R12 + MOVQ $1, BP +notphdr: + CMPL AX, $PT_DYNAMIC + JNE notdyn + MOVQ P_VADDR(SI), R13 +notdyn: + CMPL AX, $PT_GNU_RELRO + JNE phnext + MOVQ P_VADDR(SI), R14 + MOVQ P_MEMSZ(SI), CX +phnext: + ADDQ $PHDR_SIZE, SI + DECQ DI + JMP phloop +phdone: + TESTQ BP, BP + JZ fail + TESTQ R13, R13 + JZ fail + + // SI = DT_RELA, DI = DT_RELASZ + ADDQ R12, R13 + XORQ SI, SI + XORQ DI, DI +dynloop: + MOVQ 0(R13), AX + MOVQ 8(R13), BX + ADDQ $16, R13 + TESTQ AX, AX + JZ dyndone + CMPQ AX, $DT_RELA + CMOVQEQ BX, SI + CMPQ AX, $DT_RELASZ + CMOVQEQ BX, DI + JMP dynloop +dyndone: + ADDQ R12, SI + ADDQ SI, DI +relloop: + CMPQ SI, DI + JEQ reldone + MOVQ R_INFO(SI), AX + CMPQ AX, $R_X86_64_RELATIVE + JNE fail + MOVQ R_OFFSET(SI), BX + MOVQ R_ADDEND(SI), AX + ADDQ R12, AX + MOVQ AX, (R12)(BX*1) + ADDQ $RELA_SIZE, SI + JMP relloop +reldone: + TESTQ CX, CX + JZ start + ADDQ R12, R14 + LEAQ (R14)(CX*1), SI + MOVQ R11, AX + NEGQ AX + ANDQ AX, R14 + ANDQ AX, SI + SUBQ R14, SI + JZ start + MOVQ R14, DI + MOVQ $PROT_READ, DX + MOVQ $SYS_mprotect, AX + SYSCALL + TESTQ AX, AX + JNZ fail +start: + // The ABI says DX holds a function to register with atexit. + XORQ DX, DX + JMP _rt0_amd64_linux(SB) +fail: + MOVQ $EXIT_STATUS, DI + MOVQ $SYS_exit_group, AX + SYSCALL + JMP fail + +TEXT ·entryAddr(SB),NOSPLIT,$0-8 + LEAQ ·entry(SB), AX + MOVQ AX, ret+0(FP) + RET diff --git a/tsc/internal/staticpie/entry_linux_arm64.s b/tsc/internal/staticpie/entry_linux_arm64.s new file mode 100644 index 0000000000000..f68885f3253a8 --- /dev/null +++ b/tsc/internal/staticpie/entry_linux_arm64.s @@ -0,0 +1,123 @@ +#include "textflag.h" +#include "staticpie.h" + +#define R_AARCH64_RELATIVE 1027 +#define SYS_mprotect 226 +#define SYS_exit_group 94 + +TEXT ·entry(SB),NOSPLIT|NOFRAME,$0 + // Skip argc, argv, and envp to find auxv. + MOVD 0(RSP), R1 + ADD $16, RSP, R0 + ADD R1<<3, R0, R0 +envloop: + MOVD.P 8(R0), R2 + CBNZ R2, envloop + + // R9 = AT_PHDR, R10 = AT_PHNUM, R11 = AT_PAGESZ + MOVD ZR, R9 + MOVD ZR, R10 + MOVD ZR, R11 +auxloop: + MOVD 0(R0), R2 + MOVD 8(R0), R3 + ADD $16, R0 + CBZ R2, auxdone + CMP $AT_PHDR, R2 + CSEL EQ, R3, R9, R9 + CMP $AT_PHNUM, R2 + CSEL EQ, R3, R10, R10 + CMP $AT_PAGESZ, R2 + CSEL EQ, R3, R11, R11 + B auxloop +auxdone: + CBZ R9, fail + CBZ R10, fail + CBZ R11, fail + + // R12 = load bias, R13 = PT_PHDR seen, R14 = PT_DYNAMIC vaddr, + // R15 = PT_GNU_RELRO vaddr, R16 = PT_GNU_RELRO memsz + MOVD R9, R4 + MOVD R10, R5 + MOVD ZR, R13 + MOVD ZR, R14 + MOVD ZR, R15 + MOVD ZR, R16 + MOVD $1, R7 + MOVD $PT_GNU_RELRO, R17 +phloop: + CBZ R5, phdone + MOVWU 0(R4), R2 + MOVD P_VADDR(R4), R3 + MOVD P_MEMSZ(R4), R6 + SUB R3, R9, R8 + CMP $PT_PHDR, R2 + CSEL EQ, R8, R12, R12 + CSEL EQ, R7, R13, R13 + CMP $PT_DYNAMIC, R2 + CSEL EQ, R3, R14, R14 + CMP R17, R2 + CSEL EQ, R3, R15, R15 + CSEL EQ, R6, R16, R16 + ADD $PHDR_SIZE, R4 + SUB $1, R5 + B phloop +phdone: + CBZ R13, fail + CBZ R14, fail + + // R4 = DT_RELA, R5 = DT_RELASZ + ADD R12, R14 + MOVD ZR, R4 + MOVD ZR, R5 +dynloop: + MOVD 0(R14), R2 + MOVD 8(R14), R3 + ADD $16, R14 + CBZ R2, dyndone + CMP $DT_RELA, R2 + CSEL EQ, R3, R4, R4 + CMP $DT_RELASZ, R2 + CSEL EQ, R3, R5, R5 + B dynloop +dyndone: + ADD R12, R4 + ADD R4, R5 +relloop: + CMP R4, R5 + BEQ reldone + MOVD R_INFO(R4), R2 + CMP $R_AARCH64_RELATIVE, R2 + BNE fail + MOVD R_OFFSET(R4), R2 + MOVD R_ADDEND(R4), R3 + ADD R12, R3 + MOVD R3, (R12)(R2) + ADD $RELA_SIZE, R4 + B relloop +reldone: + CBZ R16, start + ADD R12, R15 + ADD R15, R16, R1 + NEG R11, R2 + AND R2, R15 + AND R2, R1 + SUB R15, R1, R1 + CBZ R1, start + MOVD R15, R0 + MOVD $PROT_READ, R2 + MOVD $SYS_mprotect, R8 + SVC + CBNZ R0, fail +start: + B _rt0_arm64_linux(SB) +fail: + MOVD $EXIT_STATUS, R0 + MOVD $SYS_exit_group, R8 + SVC + B fail + +TEXT ·entryAddr(SB),NOSPLIT,$0-8 + MOVD $·entry(SB), R0 + MOVD R0, ret+0(FP) + RET diff --git a/tsc/internal/staticpie/entry_linux_loong64.s b/tsc/internal/staticpie/entry_linux_loong64.s new file mode 100644 index 0000000000000..bb3671b9c6775 --- /dev/null +++ b/tsc/internal/staticpie/entry_linux_loong64.s @@ -0,0 +1,135 @@ +#include "textflag.h" +#include "staticpie.h" + +#define R_LARCH_RELATIVE 3 +#define SYS_mprotect 226 +#define SYS_exit_group 94 + +TEXT ·entry(SB),NOSPLIT|NOFRAME,$0 + // Skip argc, argv, and envp to find auxv. + MOVV 0(R3), R5 + SLLV $3, R5 + ADDV R3, R5 + ADDV $16, R5 +envloop: + MOVV 0(R5), R6 + ADDV $8, R5 + BNE R6, envloop + + // R7 = AT_PHDR, R8 = AT_PHNUM, R9 = AT_PAGESZ + MOVV R0, R7 + MOVV R0, R8 + MOVV R0, R9 +auxloop: + MOVV 0(R5), R6 + MOVV 8(R5), R10 + ADDV $16, R5 + BEQ R6, auxdone + MOVV $AT_PHDR, R19 + BNE R6, R19, notphdraux + MOVV R10, R7 +notphdraux: + MOVV $AT_PHNUM, R19 + BNE R6, R19, notphnum + MOVV R10, R8 +notphnum: + MOVV $AT_PAGESZ, R19 + BNE R6, R19, auxloop + MOVV R10, R9 + JMP auxloop +auxdone: + BEQ R7, fail + BEQ R8, fail + BEQ R9, fail + + // R14 = load bias, R15 = PT_PHDR seen, R16 = PT_DYNAMIC vaddr, + // R17 = PT_GNU_RELRO vaddr, R18 = PT_GNU_RELRO memsz + MOVV R7, R12 + MOVV R8, R13 + MOVV R0, R15 + MOVV R0, R16 + MOVV R0, R17 + MOVV R0, R18 +phloop: + BEQ R13, phdone + MOVWU 0(R12), R6 + MOVV P_VADDR(R12), R10 + MOVV $PT_PHDR, R19 + BNE R6, R19, notphdr + SUBV R10, R7, R14 + MOVV $1, R15 +notphdr: + MOVV $PT_DYNAMIC, R19 + BNE R6, R19, notdyn + MOVV R10, R16 +notdyn: + MOVV $PT_GNU_RELRO, R19 + BNE R6, R19, phnext + MOVV R10, R17 + MOVV P_MEMSZ(R12), R18 +phnext: + ADDV $PHDR_SIZE, R12 + SUBV $1, R13 + JMP phloop +phdone: + BEQ R15, fail + BEQ R16, fail + + // R12 = DT_RELA, R13 = DT_RELASZ + ADDV R14, R16 + MOVV R0, R12 + MOVV R0, R13 +dynloop: + MOVV 0(R16), R6 + MOVV 8(R16), R10 + ADDV $16, R16 + BEQ R6, dyndone + MOVV $DT_RELA, R19 + BNE R6, R19, notrela + MOVV R10, R12 +notrela: + MOVV $DT_RELASZ, R19 + BNE R6, R19, dynloop + MOVV R10, R13 + JMP dynloop +dyndone: + ADDV R14, R12 + ADDV R12, R13 + MOVV $R_LARCH_RELATIVE, R19 +relloop: + BEQ R12, R13, reldone + MOVV R_INFO(R12), R6 + BNE R6, R19, fail + MOVV R_OFFSET(R12), R6 + MOVV R_ADDEND(R12), R10 + ADDV R14, R10 + ADDV R14, R6 + MOVV R10, 0(R6) + ADDV $RELA_SIZE, R12 + JMP relloop +reldone: + BEQ R18, start + ADDV R14, R17 + ADDV R17, R18, R6 + SUBV R9, R0, R10 + AND R10, R17 + AND R10, R6 + SUBV R17, R6, R5 + BEQ R5, start + MOVV R17, R4 + MOVV $PROT_READ, R6 + MOVV $SYS_mprotect, R11 + SYSCALL + BNE R4, fail +start: + JMP _rt0_loong64_linux(SB) +fail: + MOVV $EXIT_STATUS, R4 + MOVV $SYS_exit_group, R11 + SYSCALL + JMP fail + +TEXT ·entryAddr(SB),NOSPLIT,$0-8 + MOVV $·entry(SB), R4 + MOVV R4, ret+0(FP) + RET diff --git a/tsc/internal/staticpie/entry_linux_ppc64le.s b/tsc/internal/staticpie/entry_linux_ppc64le.s new file mode 100644 index 0000000000000..368d80c0093ea --- /dev/null +++ b/tsc/internal/staticpie/entry_linux_ppc64le.s @@ -0,0 +1,150 @@ +#include "textflag.h" +#include "staticpie.h" + +#define R_PPC64_RELATIVE 22 +#define SYS_mprotect 125 +#define SYS_exit_group 234 + +// The kernel sets R12 to the entry address, from which the assembler's +// prologue computes R2 (the TOC pointer), and R13 to zero, which the runtime +// relies on. Neither is touched here, nor is R30 (g) or R31 (REGTMP). +TEXT ·entry(SB),NOSPLIT|NOFRAME,$0 + // Skip argc, argv, and envp to find auxv. + MOVD 0(R1), R5 + SLD $3, R5 + ADD R1, R5 + ADD $16, R5 +envloop: + MOVD 0(R5), R6 + ADD $8, R5 + CMP R6, $0 + BNE envloop + + // R7 = AT_PHDR, R8 = AT_PHNUM, R9 = AT_PAGESZ + MOVD $0, R7 + MOVD $0, R8 + MOVD $0, R9 +auxloop: + MOVD 0(R5), R6 + MOVD 8(R5), R10 + ADD $16, R5 + CMP R6, $0 + BEQ auxdone + CMP R6, $AT_PHDR + BNE notphdraux + MOVD R10, R7 +notphdraux: + CMP R6, $AT_PHNUM + BNE notphnum + MOVD R10, R8 +notphnum: + CMP R6, $AT_PAGESZ + BNE auxloop + MOVD R10, R9 + BR auxloop +auxdone: + CMP R7, $0 + BEQ fail + CMP R8, $0 + BEQ fail + CMP R9, $0 + BEQ fail + + // R14 = load bias, R15 = PT_PHDR seen, R16 = PT_DYNAMIC vaddr, + // R17 = PT_GNU_RELRO vaddr, R18 = PT_GNU_RELRO memsz + MOVD R7, R11 + MOVD R8, R19 + MOVD $0, R15 + MOVD $0, R16 + MOVD $0, R17 + MOVD $0, R18 + MOVD $PT_GNU_RELRO, R20 +phloop: + CMP R19, $0 + BEQ phdone + MOVWZ 0(R11), R6 + MOVD P_VADDR(R11), R10 + CMP R6, $PT_PHDR + BNE notphdr + SUB R10, R7, R14 + MOVD $1, R15 +notphdr: + CMP R6, $PT_DYNAMIC + BNE notdyn + MOVD R10, R16 +notdyn: + CMP R6, R20 + BNE phnext + MOVD R10, R17 + MOVD P_MEMSZ(R11), R18 +phnext: + ADD $PHDR_SIZE, R11 + ADD $-1, R19 + BR phloop +phdone: + CMP R15, $0 + BEQ fail + CMP R16, $0 + BEQ fail + + // R11 = DT_RELA, R19 = DT_RELASZ + ADD R14, R16 + MOVD $0, R11 + MOVD $0, R19 +dynloop: + MOVD 0(R16), R6 + MOVD 8(R16), R10 + ADD $16, R16 + CMP R6, $0 + BEQ dyndone + CMP R6, $DT_RELA + BNE notrela + MOVD R10, R11 +notrela: + CMP R6, $DT_RELASZ + BNE dynloop + MOVD R10, R19 + BR dynloop +dyndone: + ADD R14, R11 + ADD R11, R19 +relloop: + CMP R11, R19 + BEQ reldone + MOVD R_INFO(R11), R6 + CMP R6, $R_PPC64_RELATIVE + BNE fail + MOVD R_OFFSET(R11), R6 + MOVD R_ADDEND(R11), R10 + ADD R14, R10 + MOVD R10, (R14)(R6) + ADD $RELA_SIZE, R11 + BR relloop +reldone: + CMP R18, $0 + BEQ start + ADD R14, R17 + ADD R17, R18, R6 + NEG R9, R10 + AND R10, R17 + AND R10, R6 + SUB R17, R6, R4 + CMP R4, $0 + BEQ start + MOVD R17, R3 + MOVD $PROT_READ, R5 + MOVD $SYS_mprotect, R0 + SYSCALL + BVS fail +start: + BR _rt0_ppc64le_linux(SB) +fail: + MOVD $EXIT_STATUS, R3 + MOVD $SYS_exit_group, R0 + SYSCALL + BR fail + +TEXT ·entryAddr(SB),NOSPLIT,$0-8 + MOVD $·entry(SB), R3 + MOVD R3, ret+0(FP) + RET diff --git a/tsc/internal/staticpie/entry_linux_s390x.s b/tsc/internal/staticpie/entry_linux_s390x.s new file mode 100644 index 0000000000000..0860938a6ec1c --- /dev/null +++ b/tsc/internal/staticpie/entry_linux_s390x.s @@ -0,0 +1,127 @@ +#include "textflag.h" +#include "staticpie.h" + +#define R_390_RELATIVE 12 +#define SYS_mprotect 125 +#define SYS_exit_group 248 + +// R10 and R11 (REGTMP) and R13 (g) are avoided. +TEXT ·entry(SB),NOSPLIT|NOFRAME,$0 + // Skip argc, argv, and envp to find auxv. + MOVD 0(R15), R5 + SLD $3, R5 + ADD R15, R5 + ADD $16, R5 +envloop: + MOVD 0(R5), R6 + ADD $8, R5 + CMPBNE R6, $0, envloop + + // R7 = AT_PHDR, R8 = AT_PHNUM, R9 = AT_PAGESZ + MOVD $0, R7 + MOVD $0, R8 + MOVD $0, R9 +auxloop: + MOVD 0(R5), R6 + MOVD 8(R5), R12 + ADD $16, R5 + CMPBEQ R6, $0, auxdone + CMPBNE R6, $AT_PHDR, notphdraux + MOVD R12, R7 +notphdraux: + CMPBNE R6, $AT_PHNUM, notphnum + MOVD R12, R8 +notphnum: + CMPBNE R6, $AT_PAGESZ, auxloop + MOVD R12, R9 + BR auxloop +auxdone: + CMPBEQ R7, $0, fail + CMPBEQ R8, $0, fail + CMPBEQ R9, $0, fail + + // R14 = load bias, R0 = PT_PHDR seen, R1 = PT_DYNAMIC vaddr, + // R2 = PT_GNU_RELRO vaddr, R3 = PT_GNU_RELRO memsz + MOVD R7, R4 + MOVD R8, R5 + MOVD $0, R0 + MOVD $0, R1 + MOVD $0, R2 + MOVD $0, R3 + MOVD $PT_GNU_RELRO, R6 +phloop: + CMPBEQ R5, $0, phdone + MOVWZ 0(R4), R12 + MOVD P_VADDR(R4), R8 + CMPBNE R12, $PT_PHDR, notphdr + SUB R8, R7, R14 + MOVD $1, R0 +notphdr: + CMPBNE R12, $PT_DYNAMIC, notdyn + MOVD R8, R1 +notdyn: + CMPBNE R12, R6, phnext + MOVD R8, R2 + MOVD P_MEMSZ(R4), R3 +phnext: + ADD $PHDR_SIZE, R4 + SUB $1, R5 + BR phloop +phdone: + CMPBEQ R0, $0, fail + CMPBEQ R1, $0, fail + + // R4 = DT_RELA, R5 = DT_RELASZ + ADD R14, R1 + MOVD $0, R4 + MOVD $0, R5 +dynloop: + MOVD 0(R1), R12 + MOVD 8(R1), R8 + ADD $16, R1 + CMPBEQ R12, $0, dyndone + CMPBNE R12, $DT_RELA, notrela + MOVD R8, R4 +notrela: + CMPBNE R12, $DT_RELASZ, dynloop + MOVD R8, R5 + BR dynloop +dyndone: + ADD R14, R4 + ADD R4, R5 +relloop: + CMPBEQ R4, R5, reldone + MOVD R_INFO(R4), R12 + CMPBNE R12, $R_390_RELATIVE, fail + MOVD R_OFFSET(R4), R12 + MOVD R_ADDEND(R4), R8 + ADD R14, R8 + MOVD R8, 0(R14)(R12*1) + ADD $RELA_SIZE, R4 + BR relloop +reldone: + CMPBEQ R3, $0, start + ADD R14, R2 + ADD R2, R3 + MOVD R9, R12 + NEG R12 + AND R12, R2 + AND R12, R3 + SUB R2, R3 + CMPBEQ R3, $0, start + MOVD $PROT_READ, R4 + MOVD $SYS_mprotect, R1 + SYSCALL + CMPBNE R2, $0, fail +start: + BR _rt0_s390x_linux(SB) +fail: + MOVD $EXIT_STATUS, R2 + MOVD $SYS_exit_group, R1 + SYSCALL + BR fail + +TEXT ·entryAddr(SB),NOSPLIT,$0-8 + MOVD $·entry(SB), R2 + MOVD R2, ret+0(FP) + RET diff --git a/tsc/internal/staticpie/staticpie.go b/tsc/internal/staticpie/staticpie.go new file mode 100644 index 0000000000000..5451568c491d0 --- /dev/null +++ b/tsc/internal/staticpie/staticpie.go @@ -0,0 +1,18 @@ +// Package staticpie provides an entry point that lets a Go-linked +// position-independent executable start without an ELF interpreter. +// +// Go's internal linker can only produce PIEs that ask the system's dynamic +// loader (e.g. /lib64/ld-linux-x86-64.so.2) to apply their relocations. That +// loader path does not exist on musl-based systems like Alpine, in distroless +// containers, or on NixOS, so those binaries fail to start there. +// +// The tools/cmd/staticpie post-processor removes PT_INTERP from such a binary +// and points its ELF entry at the entry function in this package, which does +// the loader's remaining work (applying R_*_RELATIVE relocations and +// write-protecting PT_GNU_RELRO) before jumping to the Go runtime's usual +// entry point. The result is a static-PIE that runs on any Linux system. +// +// This package must be linked into the binary for the post-processor to find +// the entry function; it has no effect on binaries that are not +// post-processed. +package staticpie diff --git a/tsc/internal/staticpie/staticpie.h b/tsc/internal/staticpie/staticpie.h new file mode 100644 index 0000000000000..3b2c07db11fb8 --- /dev/null +++ b/tsc/internal/staticpie/staticpie.h @@ -0,0 +1,24 @@ +#define AT_PHDR 3 +#define AT_PHNUM 5 +#define AT_PAGESZ 6 + +#define PT_DYNAMIC 2 +#define PT_PHDR 6 +#define PT_GNU_RELRO 0x6474e552 + +#define DT_RELA 7 +#define DT_RELASZ 8 + +// Elf64_Phdr field offsets and size. +#define P_VADDR 16 +#define P_MEMSZ 40 +#define PHDR_SIZE 56 + +// Elf64_Rela field offsets and size. +#define R_OFFSET 0 +#define R_INFO 8 +#define R_ADDEND 16 +#define RELA_SIZE 24 + +#define PROT_READ 1 +#define EXIT_STATUS 127 From 8f5d1c662e97c2d5b330172b1dd9aa284bbee29b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:20:23 -0800 Subject: [PATCH 2/4] Build Linux release binaries as static-PIE where Go supports it On linux amd64, arm64, loong64, ppc64le, and s390x, Go can link a PIE without an external linker, so build those as PIE with BIND_NOW and convert them to static-PIE to keep them portable across libcs. Other Linux targets would need an external linker for PIE and stay as they are; Windows, macOS, and Android binaries are already PIE. --- Herebyfile.mjs | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 92125c31df3a8..e3007f916992c 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -267,14 +267,42 @@ export const lib = task({ /** * Gets the release build flags for stripping debug info. * @param {string} [versionOverride] Optional version to embed in the binary. + * @param {object} [opts] + * @param {boolean} [opts.staticPie] Build a PIE to be converted by {@link convertToStaticPie}. * @returns {string[]} */ -function getReleaseBuildFlags(versionOverride) { +function getReleaseBuildFlags(versionOverride, opts) { let ldflags = "-ldflags=-s -w"; + if (opts?.staticPie) { + ldflags += " -bindnow"; + } if (versionOverride) { ldflags += ` -X github.com/microsoft/TypeScript/tsc/internal/core.version=${versionOverride}`; } - return ["-trimpath", ldflags]; + return ["-trimpath", ...(opts?.staticPie ? ["-buildmode=pie"] : []), ldflags]; +} + +// Linux targets where Go's internal linker can produce a PIE without cgo. +// Go's PIEs require the glibc loader, so these are converted to static-PIEs +// which also run on musl (Alpine), distroless, and NixOS. Other Linux targets +// would need an external linker for PIE, so they stay non-PIE. Windows, +// macOS, and Android are already PIE by default. +const staticPieGoarches = new Set(["amd64", "arm64", "loong64", "ppc64le", "s390x"]); + +/** + * @param {string} goos + * @param {string} goarch + */ +function supportsStaticPie(goos, goarch) { + return goos === "linux" && staticPieGoarches.has(goarch); +} + +/** + * Removes the ELF interpreter from a Go-linked PIE so it starts via tsc/internal/staticpie. + * @param {string} exe + */ +async function convertToStaticPie(exe) { + await run("go", ["-C", "./tools", "run", "./cmd/staticpie", path.resolve(exe)]); } /** @@ -2740,6 +2768,7 @@ async function buildNativePreviewPlatform({ npmDir, npmPackageName, nodeOs, node await generateLibs(out); const exeName = nativePreviewExeName(nodeOs); + const staticPie = !options.respectGoEnv && supportsStaticPie(goos, goarch); await buildTsc({ out: publishAsTypescript ? path.join(out, exeName) : out, env: { @@ -2749,8 +2778,12 @@ async function buildNativePreviewPlatform({ npmDir, npmPackageName, nodeOs, node }, extraFlags: options.respectGoEnv ? [] - : getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined), + : getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined, { staticPie }), }); + if (staticPie) { + // Without -o naming a file, go build names the binary after ./cmd/tsc. + await convertToStaticPie(publishAsTypescript ? path.join(out, exeName) : path.join(out, "tsc")); + } } async function createNativePreviewInputPackageJson() { From debdef284fbffc6e72418d05ec1e89799ee97ff9 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:28:39 -0800 Subject: [PATCH 3/4] Only accept known dynamic tags and program headers in staticpie The converter rejected a fixed list of things it knew needed a dynamic loader, so anything new that a future Go linker emits for the loader to act on (a hardening property note, a new relocation format) would have been silently dropped along with PT_INTERP. Accept only the program headers, dynamic tags, and flag bits that Go emits today and that the entry point fully handles, so such a change fails the build instead. --- tools/cmd/staticpie/main.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tools/cmd/staticpie/main.go b/tools/cmd/staticpie/main.go index d903db8bea59c..eb5e035b47ef9 100644 --- a/tools/cmd/staticpie/main.go +++ b/tools/cmd/staticpie/main.go @@ -103,6 +103,11 @@ func convert(data []byte) error { dynamic = p case elf.PT_PHDR: hasPhdr = true + case elf.PT_NULL, elf.PT_LOAD, elf.PT_NOTE, elf.PT_TLS, elf.PT_GNU_STACK, elf.PT_GNU_RELRO: + default: + // Unknown headers (e.g. PT_GNU_PROPERTY) may ask the loader + // for something the entry function does not do. + return fmt.Errorf("unsupported program header %v", p.Type) } } if interp < 0 { @@ -160,12 +165,24 @@ loop: return nil, fmt.Errorf("unexpected DT_RELAENT %d", val) } case elf.DT_FLAGS: + if flags := elf.DynFlag(val); flags&^elf.DF_BIND_NOW != 0 { + return nil, fmt.Errorf("unsupported DT_FLAGS %v", flags) + } bindNow = bindNow || elf.DynFlag(val)&elf.DF_BIND_NOW != 0 case elf.DT_FLAGS_1: + if flags := elf.DynFlag1(val); flags&^(elf.DF_1_NOW|elf.DF_1_PIE) != 0 { + return nil, fmt.Errorf("unsupported DT_FLAGS_1 %v", flags) + } bindNow = bindNow || elf.DynFlag1(val)&elf.DF_1_NOW != 0 - case elf.DT_NEEDED, elf.DT_REL, elf.DynTag(36) /* DT_RELR */, elf.DT_JMPREL, elf.DT_TEXTREL, - elf.DT_INIT, elf.DT_INIT_ARRAY, elf.DT_PREINIT_ARRAY: - return nil, fmt.Errorf("has %v, which requires a dynamic loader", tag) + case elf.DT_HASH, elf.DT_STRTAB, elf.DT_STRSZ, elf.DT_SYMTAB, elf.DT_SYMENT, elf.DT_DEBUG: + // The symbol tables are for debuggers; DT_DEBUG is filled in + // by a loader, if present. + case elf.DT_PLTGOT, elf.DT_PPC64_OPT: + // Without DT_JMPREL there are no PLT entries for a loader to + // resolve, and DT_PPC64_OPT only enables loader optimizations. + default: + // Anything else may need a loader to act on it. + return nil, fmt.Errorf("unsupported dynamic tag %v", tag) } } // The entry function applies all relocations up front, so the binary From 21dc059a49c548403f8af833280a800460ac9e55 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:48:23 -0800 Subject: [PATCH 4/4] Fix static PIE converter lint diagnostics Treat the standard ELF relocation name as a technical term rather than a spelling error. Keep converter errors unshadowed and make accepted program headers explicit. --- .golangci.yml | 4 ++++ tools/cmd/staticpie/main.go | 17 +++++++++-------- tsc/internal/staticpie/entry.go | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 72b7797fa8072..c1a532979f3ef 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -65,6 +65,10 @@ linters: customlint: type: module + misspell: + ignore-rules: + - rela # ELF relocation entries with explicit addends. + depguard: rules: main: diff --git a/tools/cmd/staticpie/main.go b/tools/cmd/staticpie/main.go index eb5e035b47ef9..dfea83908bcc9 100644 --- a/tools/cmd/staticpie/main.go +++ b/tools/cmd/staticpie/main.go @@ -56,8 +56,8 @@ func run(path string) error { if err != nil { return err } - if err := convert(data); err != nil { - return err + if convertErr := convert(data); convertErr != nil { + return convertErr } info, err := os.Stat(path) if err != nil { @@ -104,6 +104,7 @@ func convert(data []byte) error { case elf.PT_PHDR: hasPhdr = true case elf.PT_NULL, elf.PT_LOAD, elf.PT_NOTE, elf.PT_TLS, elf.PT_GNU_STACK, elf.PT_GNU_RELRO: + continue default: // Unknown headers (e.g. PT_GNU_PROPERTY) may ask the loader // for something the entry function does not do. @@ -117,13 +118,13 @@ func convert(data []byte) error { return errors.New("missing PT_DYNAMIC or PT_PHDR") } - rela, err := checkDynamic(f, dynamic) + relocations, err := checkDynamic(f, dynamic) if err != nil { return err } - for off := 0; off < len(rela); off += relaSize { + for off := 0; off < len(relocations); off += relaSize { // Comparing all of r_info also checks that the symbol index is zero. - if info := bo.Uint64(rela[off+8:]); info != relative { + if info := bo.Uint64(relocations[off+8:]); info != relative { return fmt.Errorf("unsupported relocation with r_info %#x", info) } } @@ -196,13 +197,13 @@ loop: if relaSz == 0 { return nil, nil } - rela := make([]byte, relaSz) + relocations := make([]byte, relaSz) for _, p := range f.Progs { if p.Type == elf.PT_LOAD && p.Vaddr <= relaAddr && relaAddr+relaSz <= p.Vaddr+p.Filesz { - if _, err := p.ReadAt(rela, int64(relaAddr-p.Vaddr)); err != nil { + if _, err := p.ReadAt(relocations, int64(relaAddr-p.Vaddr)); err != nil { return nil, err } - return rela, nil + return relocations, nil } } return nil, errors.New("DT_RELA is not within a loaded segment") diff --git a/tsc/internal/staticpie/entry.go b/tsc/internal/staticpie/entry.go index ed0dd7150e345..a4796d83364e8 100644 --- a/tsc/internal/staticpie/entry.go +++ b/tsc/internal/staticpie/entry.go @@ -9,7 +9,7 @@ package staticpie // // 1. finds the auxiliary vector above argv and envp on the initial stack; // 2. uses AT_PHDR and the PT_PHDR program header to compute the load bias; -// 3. applies every DT_RELA relocation, failing on anything other than +// 3. applies every dynamic relocation, failing on anything other than // R_*_RELATIVE; // 4. makes the page-aligned part of PT_GNU_RELRO read-only, as ld.so does; // 5. jumps to the runtime's _rt0__linux with the stack untouched.