O artigo mencionado por sgbj nos comentários escritos por Paul Turner, do Google, explica o seguinte com muito mais detalhes, mas vou tentar:
Tanto quanto eu posso juntar isso a partir das informações limitadas no momento, um retpoline é um trampolim de retorno que usa um loop infinito que nunca é executado para impedir que a CPU especule sobre o alvo de um salto indireto.
A abordagem básica pode ser vista na ramificação do kernel de Andi Kleen, que trata desse problema:
Ele introduz a nova __x86.indirect_thunk
chamada que carrega o destino da chamada cujo endereço de memória (que eu chamarei ADDR
) está armazenado no topo da pilha e executa o salto usando uma RET
instrução. O thunk em si é chamado usando a macro NOSPEC_JMP / CALL , que foi usada para substituir muitas (se não todas) chamadas e saltos indiretos. A macro simplesmente coloca o destino da chamada na pilha e define o endereço de retorno corretamente, se necessário (observe o fluxo de controle não linear):
.macro NOSPEC_CALL target
jmp 1221f /* jumps to the end of the macro */
1222:
push \target /* pushes ADDR to the stack */
jmp __x86.indirect_thunk /* executes the indirect jump */
1221:
call 1222b /* pushes the return address to the stack */
.endm
A colocação de call
no final é necessária para que, quando a chamada indireta for concluída, o fluxo de controle continue por trás do uso da NOSPEC_CALL
macro, para que possa ser usado no lugar de uma consulta regular.call
O thunk em si tem a seguinte aparência:
call retpoline_call_target
2:
lfence /* stop speculation */
jmp 2b
retpoline_call_target:
lea 8(%rsp), %rsp
ret
O fluxo de controle pode ficar um pouco confuso aqui, então deixe-me esclarecer:
call
empurra o ponteiro de instrução atual (etiqueta 2) para a pilha.
lea
adiciona 8 ao ponteiro da pilha , descartando efetivamente a palavra-chave empurrada mais recentemente, que é o último endereço de retorno (no rótulo 2). Depois disso, a parte superior da pilha aponta para o endereço de retorno real ADDR novamente.
ret
pula *ADDR
e redefine o ponteiro da pilha para o início da pilha de chamadas.
No final, todo esse comportamento é praticamente equivalente a pular diretamente para *ADDR
. O único benefício que obtemos é que o preditor de ramificação usado para instruções de retorno (Buffer de Pilha de Retorno, RSB), ao executar a call
instrução, assume que a ret
instrução correspondente passará para o rótulo 2.
A parte depois que o rótulo 2 nunca é executado, é simplesmente um loop infinito que, em teoria, preencheria o pipeline de JMP
instruções com instruções. Ao usar LFENCE
, PAUSE
ou mais geralmente, uma instrução que faz com que o pipeline de instruções fique parado impede a CPU de desperdiçar energia e tempo nessa execução especulativa. Isso ocorre porque, caso a chamada para retpoline_call_target retorne normalmente, essa LFENCE
seria a próxima instrução a ser executada. Isso também é o que o preditor de ramificação irá prever com base no endereço de retorno original (o rótulo 2)
Para citar o manual de arquitetura da Intel:
As instruções após um LFENCE podem ser buscadas na memória antes do LFENCE, mas elas não serão executadas até que o LFENCE seja concluído.
Observe, no entanto, que a especificação nunca menciona que LFENCE e PAUSE causam a interrupção do pipeline, por isso estou lendo um pouco entre as linhas aqui.
Agora, de volta à sua pergunta original: A divulgação de informações da memória do kernel é possível devido à combinação de duas idéias:
Embora a execução especulativa deva ser livre de efeitos colaterais quando a especulação estiver errada, a execução especulativa ainda afeta a hierarquia do cache . Isso significa que, quando uma carga de memória é executada especulativamente, ela ainda pode ter causado a remoção de uma linha de cache. Essa alteração na hierarquia de cache pode ser identificada medindo cuidadosamente o tempo de acesso à memória que é mapeado no mesmo conjunto de cache.
Você pode até vazar alguns bits de memória arbitrária quando o endereço de origem da memória lida foi ele próprio lido na memória do kernel.
O preditor de ramificação indireta das CPUs Intel usa apenas os 12 bits mais baixos da instrução fonte, portanto, é fácil envenenar todos os 2 ^ 12 históricos de previsão possíveis com endereços de memória controlados pelo usuário. Estes podem então, quando o salto indireto é previsto dentro do kernel, ser executado especulativamente com privilégios de kernel. Usando o canal lateral de tempo de cache, você pode vazar memória arbitrária do kernel.
ATUALIZAÇÃO: Na lista de discussão do kernel , há uma discussão em andamento que me leva a crer que os retpolines não atenuam completamente os problemas de previsão de ramificação, como quando o RSB (Return Stack Buffer) fica vazio, as arquiteturas Intel mais recentes (Skylake +) retornam ao vulnerável Target Target Buffer (BTB):
Retpoline como estratégia de mitigação troca ramificações indiretas por retornos, para evitar o uso de previsões provenientes do BTB, pois elas podem ser envenenadas por um invasor. O problema com o Skylake + é que um fluxo insuficiente de RSB volta a usar uma previsão de BTB, o que permite ao invasor assumir o controle da especulação.