Seg-falha específica do gcc-10.0.1

23

Eu tenho um pacote R com código compilado C que é relativamente estável por um bom tempo e é frequentemente testado em uma ampla variedade de plataformas e compiladores (windows / osx / debian / fedora gcc / clang).

Mais recentemente, uma nova plataforma foi adicionada para testar o pacote novamente:

Logs from checks with gcc trunk aka 10.0.1 compiled from source
on Fedora 30. (For some archived packages, 10.0.0.)

x86_64 Fedora 30 Linux

FFLAGS="-g -O2 -mtune=native -Wall -fallow-argument-mismatch"
CFLAGS="-g -O2 -Wall -pedantic -mtune=native -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection"
CXXFLAGS="-g -O2 -Wall -pedantic -mtune=native -Wno-ignored-attributes -Wno-deprecated-declarations -Wno-parentheses -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection"

Nesse ponto, o código compilado imediatamente iniciou o segfaulting ao longo destas linhas:

 *** caught segfault ***
address 0x1d00000001, cause 'memory not mapped'

Consegui reproduzir o segfault de forma consistente usando o rocker/r-basecontêiner do docker gcc-10.0.1com nível de otimização -O2. A execução de uma otimização mais baixa elimina o problema. A execução de qualquer outra configuração, inclusive sob valgrind (-O0 e -O2), UBSAN (gcc / clang), não mostra nenhum problema. Também tenho certeza de que isso ocorreu gcc-10.0.0, mas não os dados.

Corri a gcc-10.0.1 -O2versão gdbe notei algo que me parece estranho:

gdb vs code

Ao percorrer a seção destacada, parece que a inicialização dos segundos elementos das matrizes é ignorada ( R_allocé um invólucro em torno do mallocqual o lixo próprio é coletado ao retornar o controle para R; o segfault ocorre antes de retornar para R). Posteriormente, o programa falha quando o elemento não inicializado (na versão gcc.10.0.1 -O2) é acessado.

Corrigi isso inicializando explicitamente o elemento em questão em qualquer lugar do código que eventualmente levou ao uso do elemento, mas ele realmente deveria ter sido inicializado com uma string vazia, ou pelo menos é o que eu teria assumido.

Estou perdendo algo óbvio ou fazendo algo estúpido? Ambos são razoavelmente prováveis, pois C é minha segunda língua de longe . É estranho que isso tenha surgido agora e não consigo descobrir o que o compilador está tentando fazer.


UPDATE : Instruções para reproduzir isso, embora isso só seja reproduzido enquanto o debian:testingcontêiner estiver gcc-10em gcc-10.0.1. Além disso, não execute esses comandos apenas se não confiar em mim .

Desculpe, este não é um exemplo mínimo reproduzível.

docker pull rocker/r-base
docker run --rm -ti --security-opt seccomp=unconfined \
  rocker/r-base /bin/bash
apt-get update
apt-get install gcc-10 gdb
gcc-10 --version  # confirm 10.0.1
# gcc-10 (Debian 10-20200222-1) 10.0.1 20200222 (experimental) 
# [master revision 01af7e0a0c2:487fe13f218:e99b18cf7101f205bfdd9f0f29ed51caaec52779]

mkdir ~/.R
touch ~/.R/Makevars
echo "CC = gcc-10
CFLAGS = -g -O2 -Wall -pedantic -mtune=native -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection
" >> ~/.R/Makevars

R -d gdb --vanilla

Em seguida, no console R, após a digitação runde obter gdbpara executar o programa:

f.dl <- tempfile()
f.uz <- tempfile()

github.url <- 'https://github.com/brodieG/vetr/archive/v0.2.8.zip'

download.file(github.url, f.dl)
unzip(f.dl, exdir=f.uz)
install.packages(
  file.path(f.uz, 'vetr-0.2.8'), repos=NULL,
  INSTALL_opts="--install-tests", type='source'
)
# minimal set of commands to segfault
library(vetr)
alike(pairlist(a=1, b="character"), pairlist(a=1, b=letters))
alike(pairlist(1, "character"), pairlist(1, letters))
alike(NULL, 1:3)                  # not a wild card at top level
alike(list(NULL), list(1:3))      # but yes when nested
alike(list(NULL, NULL), list(list(list(1, 2, 3)), 1:25))
alike(list(NULL), list(1, 2))
alike(list(), list(1, 2))
alike(matrix(integer(), ncol=7), matrix(1:21, nrow=3))
alike(matrix(character(), nrow=3), matrix(1:21, nrow=3))
alike(
  matrix(integer(), ncol=3, dimnames=list(NULL, c("R", "G", "B"))),
  matrix(1:21, ncol=3, dimnames=list(NULL, c("R", "G", "B")))
)

# Adding tests from docs

mx.tpl <- matrix(
  integer(), ncol=3, dimnames=list(row.id=NULL, c("R", "G", "B"))
)
mx.cur <- matrix(
  sample(0:255, 12), ncol=3, dimnames=list(row.id=1:4, rgb=c("R", "G", "B"))
)
mx.cur2 <-
  matrix(sample(0:255, 12), ncol=3, dimnames=list(1:4, c("R", "G", "B")))

alike(mx.tpl, mx.cur2)

A inspeção no gdb mostra rapidamente (se bem entendi) que CSR_strmlen_xestá tentando acessar a string que não foi inicializada.

ATUALIZAÇÃO 2 : esta é uma função altamente recursiva e, além disso, o bit de inicialização da string é chamado muitas e muitas vezes. Isso é principalmente porque eu estava sendo preguiçoso, precisamos apenas das seqüências inicializadas pela única vez que encontramos algo que queremos relatar na recursão, mas era mais fácil inicializar toda vez que é possível encontrar algo. Menciono isso porque o que você verá a seguir mostra várias inicializações, mas apenas uma delas (presumivelmente a com endereço <0x1400000001>) está sendo usada.

Não posso garantir que as coisas que estou mostrando aqui estejam diretamente relacionadas ao elemento que causou o segfault (embora seja o mesmo acesso ilegal ao endereço), mas como @ nate-eldredge pediu, ele mostra que o elemento array não é inicializado imediatamente antes do retorno ou logo após o retorno na função de chamada. Observe que a função de chamada está inicializando 8 deles, e eu mostro todos eles, com todos eles cheios de lixo ou memória inacessível.

insira a descrição da imagem aqui

ATUALIZAÇÃO 3 , desmontagem da função em questão:

Breakpoint 1, ALIKEC_res_strings_init () at alike.c:75
75    return res;
(gdb) p res.current[0]
$1 = 0x7ffff46a0aa5 "%s%s%s%s"
(gdb) p res.current[1]
$2 = 0x1400000001 <error: Cannot access memory at address 0x1400000001>
(gdb) disas /m ALIKEC_res_strings_init
Dump of assembler code for function ALIKEC_res_strings_init:
53  struct ALIKEC_res_strings ALIKEC_res_strings_init() {
   0x00007ffff4687fc0 <+0>: endbr64 

54    struct ALIKEC_res_strings res;

55  
56    res.target = (const char **) R_alloc(5, sizeof(const char *));
   0x00007ffff4687fc4 <+4>: push   %r12
   0x00007ffff4687fc6 <+6>: mov    $0x8,%esi
   0x00007ffff4687fcb <+11>:    mov    %rdi,%r12
   0x00007ffff4687fce <+14>:    push   %rbx
   0x00007ffff4687fcf <+15>:    mov    $0x5,%edi
   0x00007ffff4687fd4 <+20>:    sub    $0x8,%rsp
   0x00007ffff4687fd8 <+24>:    callq  0x7ffff4687180 <R_alloc@plt>
   0x00007ffff4687fdd <+29>:    mov    $0x8,%esi
   0x00007ffff4687fe2 <+34>:    mov    $0x5,%edi
   0x00007ffff4687fe7 <+39>:    mov    %rax,%rbx

57    res.current = (const char **) R_alloc(5, sizeof(const char *));
   0x00007ffff4687fea <+42>:    callq  0x7ffff4687180 <R_alloc@plt>

58  
59    res.target[0] = "%s%s%s%s";
   0x00007ffff4687fef <+47>:    lea    0x1764a(%rip),%rdx        # 0x7ffff469f640
   0x00007ffff4687ff6 <+54>:    lea    0x18aa8(%rip),%rcx        # 0x7ffff46a0aa5
   0x00007ffff4687ffd <+61>:    mov    %rcx,(%rbx)

60    res.target[1] = "";

61    res.target[2] = "";
   0x00007ffff4688000 <+64>:    mov    %rdx,0x10(%rbx)

62    res.target[3] = "";
   0x00007ffff4688004 <+68>:    mov    %rdx,0x18(%rbx)

63    res.target[4] = "";
   0x00007ffff4688008 <+72>:    mov    %rdx,0x20(%rbx)

64  
65    res.tar_pre = "be";

66  
67    res.current[0] = "%s%s%s%s";
   0x00007ffff468800c <+76>:    mov    %rax,0x8(%r12)
   0x00007ffff4688011 <+81>:    mov    %rcx,(%rax)

68    res.current[1] = "";

69    res.current[2] = "";
   0x00007ffff4688014 <+84>:    mov    %rdx,0x10(%rax)

70    res.current[3] = "";
   0x00007ffff4688018 <+88>:    mov    %rdx,0x18(%rax)

71    res.current[4] = "";
   0x00007ffff468801c <+92>:    mov    %rdx,0x20(%rax)

72  
73    res.cur_pre = "is";

74  
75    return res;
=> 0x00007ffff4688020 <+96>:    lea    0x14fe0(%rip),%rax        # 0x7ffff469d007
   0x00007ffff4688027 <+103>:   mov    %rax,0x10(%r12)
   0x00007ffff468802c <+108>:   lea    0x14fcd(%rip),%rax        # 0x7ffff469d000
   0x00007ffff4688033 <+115>:   mov    %rbx,(%r12)
   0x00007ffff4688037 <+119>:   mov    %rax,0x18(%r12)
   0x00007ffff468803c <+124>:   add    $0x8,%rsp
   0x00007ffff4688040 <+128>:   pop    %rbx
   0x00007ffff4688041 <+129>:   mov    %r12,%rax
   0x00007ffff4688044 <+132>:   pop    %r12
   0x00007ffff4688046 <+134>:   retq   
   0x00007ffff4688047:  nopw   0x0(%rax,%rax,1)

End of assembler dump.

ATUALIZAÇÃO 4 :

Então, tentando analisar o padrão aqui estão as partes dele que parecem relevantes ( rascunho C11 ):

6.3.2.3 Conversões Par7> Outros operandos> Ponteiros

Um ponteiro para um tipo de objeto pode ser convertido em um ponteiro para um tipo de objeto diferente. Se o ponteiro resultante não estiver alinhado corretamente 68) para o tipo referenciado, o comportamento será indefinido.
Caso contrário, quando convertido novamente, o resultado será comparado ao ponteiro original. Quando um ponteiro para um objeto é convertido em um ponteiro para um tipo de caractere, o resultado aponta para o byte endereçado mais baixo do objeto. Incrementos sucessivos do resultado, até o tamanho do objeto, produzem ponteiros para os bytes restantes do objeto.

6.5 Expressões Par6

O tipo efetivo de um objeto para um acesso ao seu valor armazenado é o tipo declarado do objeto, se houver. 87) Se um valor for armazenado em um objeto sem tipo declarado por meio de um lvalue com um tipo que não seja um tipo de caractere, o tipo do lvalue se tornará o tipo efetivo do objeto para esse acesso e para acessos subseqüentes que não modificar o valor armazenado. Se um valor for copiado em um objeto sem tipo declarado usando memcpy ou memmove ou for copiado como uma matriz de tipo de caractere, o tipo efetivo do objeto modificado para esse acesso e para acessos subseqüentes que não modificam o valor é o tipo efetivo do objeto do qual o valor é copiado, se houver um. Para todos os outros acessos a um objeto sem tipo declarado, o tipo efetivo do objeto é simplesmente o tipo de lvalue usado para o acesso.

87) Objetos alocados não têm tipo declarado.

IIUC R_allocretorna um deslocamento em um mallocbloco ed que é garantido para ser doublealinhado, e o tamanho do bloco após o deslocamento é do tamanho solicitado (também há alocação antes do deslocamento para dados específicos de R). R_alloclança esse ponteiro (char *)no retorno.

Seção 6.2.5 Par 29

Um ponteiro para anular deve ter os mesmos requisitos de representação e alinhamento que um ponteiro para um tipo de caractere. 48) Da mesma forma, os indicadores para versões qualificadas ou não qualificadas de tipos compatíveis devem ter os mesmos requisitos de representação e alinhamento. Todos os ponteiros para tipos de estrutura devem ter os mesmos requisitos de representação e alinhamento que os outros.
Todos os ponteiros para tipos de união devem ter os mesmos requisitos de representação e alinhamento que os outros.
Ponteiros para outros tipos não precisam ter os mesmos requisitos de representação ou alinhamento.

48) Os mesmos requisitos de representação e alinhamento significam implicar argumentos de intercambiabilidade para funções, retornar valores de funções e membros de sindicatos.

Portanto, a questão é "podemos reformular o (char *)to (const char **)e escrever nele como (const char **)". Minha leitura do exposto acima é que, enquanto os ponteiros nos sistemas em que o código é executado tiverem alinhamento compatível com o doublealinhamento, tudo bem.

Estamos violando o "aliasing estrito"? ou seja:

6.5 Par 7

Um objeto deve ter seu valor armazenado acessado apenas por uma expressão lvalue que possui um dos seguintes tipos: 88)

- um tipo compatível com o tipo efetivo do objeto ...

88) O objetivo desta lista é especificar as circunstâncias nas quais um objeto pode ou não ser aliasado.

Então, o que o compilador deve pensar que é o tipo efetivo do objeto apontado por res.target(ou res.current)? Presumivelmente, o tipo declarado (const char **), ou isso é realmente ambíguo? Parece-me que não é neste caso apenas porque não há outro 'lvalue' no escopo que acesse o mesmo objeto.

Admito que estou lutando poderosamente para extrair sentido dessas seções do padrão.

BrodieG
fonte
Se ainda não foi examinado, pode valer a pena examinar a desmontagem para ver exatamente o que está sendo feito. E também para comparar a desmontagem entre as versões do gcc.
kaylum
2
Eu não tentaria mexer com a versão de tronco do GCC. É bom se divertir, mas é chamado de tronco por um motivo. Infelizmente, é quase impossível dizer o que há de errado sem (1) ter seu código e a configuração exata (2) ter a mesma versão do GCC (3) na mesma arquitetura. Eu sugiro verificar se isso persiste quando 10.0.1 se move do tronco para o estável.
Marco Bonelli
11
Mais um comentário: -mtune=nativeotimiza para a CPU específica que sua máquina possui. Isso será diferente para testadores diferentes e pode fazer parte do problema. Se você executar a compilação, -vpoderá ver qual família de CPUs está na sua máquina (por exemplo, -mtune=skylakeno meu computador).
Nate Eldredge
11
Ainda é difícil distinguir as execuções de depuração. A desmontagem deve ser conclusiva. Você não precisa extrair nada, basta encontrar o arquivo .o produzido ao compilar o projeto e desmontá-lo. Você também pode usar as disassembleinstruções dentro do gdb.
Nate Eldredge
5
De qualquer forma, parabéns, você é um dos poucos raros cujo problema realmente foi um bug do compilador.
Nate Eldredge

Respostas:

22

Resumo: parece ser um bug no gcc, relacionado à otimização de strings. Um testcase independente está abaixo. Inicialmente, havia alguma dúvida sobre se o código está correto, mas acho que sim.

Eu relatei o bug como PR 93982 . Uma correção proposta foi confirmada, mas não a corrige em todos os casos, levando ao acompanhamento PR 94015 ( link godbolt ).

Você deve conseguir solucionar o erro compilando com o sinalizador -fno-optimize-strlen .


Consegui reduzir seu caso de teste ao seguinte exemplo mínimo (também no godbolt ):

struct a {
    const char ** target;
};

char* R_alloc(void);

struct a foo(void) {
    struct a res;
    res.target = (const char **) R_alloc();
    res.target[0] = "12345678";
    res.target[1] = "";
    res.target[2] = "";
    res.target[3] = "";
    res.target[4] = "";
    return res;
}

Com o tronco do gcc (versão do gcc 10.0.1 20200225 (experimental)) e -O2(todas as outras opções se tornaram desnecessárias), o assembly gerado no amd64 é o seguinte:

.LC0:
        .string "12345678"
.LC1:
        .string ""
foo:
        subq    $8, %rsp
        call    R_alloc
        movq    $.LC0, (%rax)
        movq    $.LC1, 16(%rax)
        movq    $.LC1, 24(%rax)
        movq    $.LC1, 32(%rax)
        addq    $8, %rsp
        ret

Portanto, você está certo de que o compilador está falhando ao inicializar res.target[1](observe a evidente ausência de movq $.LC1, 8(%rax)).

É interessante brincar com o código e ver o que afeta o "bug". Talvez significativamente, alterando o tipo de retorno de R_allocparavoid * fazer que ele desapareça e fornecer a saída "correta" da montagem. Talvez menos significativamente, mas de maneira mais divertida, alterar a corda "12345678"para ser mais longa ou mais curta também a faz desaparecer.


Discussão anterior, agora resolvida - o código é aparentemente legal.

A questão que tenho é se o seu código é realmente legal. O fato de você pegar o char *retorno R_alloc()e convertê-lo em const char **e depois armazenar um const char *parece violar a regra estrita de alias , como chare const char *não são tipos compatíveis. Há uma exceção que permite acessar qualquer objeto comochar (para implementar coisas como memcpy), mas é o contrário, e, pelo que entendi, isso não é permitido. Isso faz seu código produzir um comportamento indefinido e, assim, o compilador pode legalmente fazer o que quiser.

Se for assim, a correção correta seria para R alterar seu código para que R_alloc()retorne em void *vez de char *. Então não haveria problema de apelido. Infelizmente, esse código está fora do seu controle e não está claro para mim como você pode usar essa função sem violar o aliasing estrito. Uma solução alternativa pode ser interpor uma variável temporária, por exemplo, void *tmp = R_alloc(); res.target = tmp;que resolve o problema no caso de teste, mas ainda não tenho certeza se é legal.

No entanto, não tenho certeza dessa hipótese de "aliasing estrito", porque a compilação com a -fno-strict-aliasingqual o AFAIK deve fazer com que o gcc permita tais construções, não faz o problema desaparecer!


Atualizar. Tentando algumas opções diferentes, descobri que -fno-optimize-strlenou -fno-tree-forwpropresultará na geração de código "correto". Além disso, o uso -O1 -foptimize-strlengera o código incorreto (mas -O1 -ftree-forwpropnão).

Após um pequeno git bisectexercício, o erro parece ter sido introduzido no commit 34fcf41e30ff56155e996f5e04 .


Atualização 2. Tentei me aprofundar um pouco na fonte gcc, apenas para ver o que pude aprender. (Eu não pretendo ser nenhum tipo de especialista em compilador!)

Parece que o código tree-ssa-strlen.cé destinado a acompanhar as strings que aparecem no programa. Tão perto quanto eu posso dizer, o erro é que, olhando a instrução, res.target[0] = "12345678";o compilador confunde o endereço da string literal "12345678"com a própria string. (Isso parece estar relacionado a esse código suspeito que foi adicionado no commit mencionado acima, onde, se ele tenta contar os bytes de uma "string" que na verdade é um endereço, em vez disso, verifica o que esse endereço aponta.)

Então ele pensa que a declaração res.target[0] = "12345678", em vez de armazenar o endereço de "12345678"no endereço res.target, é armazenar a própria string, nesse endereço, como se a afirmação fosse strcpy(res.target, "12345678"). Observe o que está à frente que isso resultaria no nulo à direita sendo armazenado no endereço res.target+8(nesse estágio do compilador, todos os deslocamentos estão em bytes).

Agora, quando o compilador olha res.target[1] = "", ele também trata isso como se fosse strcpy(res.target+8, ""), o 8 vindo do tamanho de a char *. Ou seja, como se estivesse simplesmente armazenando um byte nulo no endereço res.target+8. No entanto, o compilador "sabe" que a instrução anterior já armazenava um byte nulo nesse mesmo endereço! Como tal, esta declaração é "redundante" e pode ser descartada ( aqui ).

Isso explica por que a string precisa ter exatamente 8 caracteres para acionar o bug. (Embora outros múltiplos de 8 também possam acionar o bug em outras situações.)

Nate Eldredge
fonte
A reformulação do FWIW para um tipo diferente de ponteiro está documentada . Eu não sei sobre aliasing para saber se está tudo bem em reformular, int*mas não em const char**.
BrodieG 27/02
Se meu entendimento de aliasing estrito estiver correto, a conversão para int *também é ilegal (ou melhor, armazenar ints lá é ilegal).
Nate Eldredge
11
Isso não tem nada a ver com a regra estrita de alias. A regra estrita de alias é acessar os dados que você já armazenou usando um identificador diferente. Como você atribui apenas aqui, ele não toca na regra estrita de alias. A conversão de ponteiros é válida quando os dois tipos de ponteiros têm os mesmos requisitos de alinhamento, mas aqui você está lançando char*e trabalhando no x86_64 ... Não vejo UB aqui, é um bug do gcc.
KamilCuk 28/02
11
Sim e não, @KamilCuk. Na terminologia do padrão, "acessar" inclui a leitura e a modificação do valor de um objeto. A regra estrita de aliasing, portanto, fala em "armazenamento". Não se limita às operações de read-back. Mas para objetos sem tipo declarado, isso é discutido pelo fato de que a gravação em um objeto desse tipo altera automaticamente seu tipo efetivo para corresponder ao que foi escrito. Os objetos sem um tipo declarado são exatamente os alocados dinamicamente (independentemente do tipo de ponteiro pelo qual são acessados); portanto, não há violação de SA aqui.
John Bollinger
2
Sim, @Nate, com essa definição de R_alloc(), o programa está em conformidade, independentemente da unidade de tradução R_alloc()definida. É o compilador que falha em conformidade aqui.
John Bollinger