Como verificar um helper SSH antes de iniciá-lo
Aprenda a verificar um helper SSH no macOS: local no bundle, assinatura, proprietário, identidade do arquivo e requisito no momento da inicialização.

Um gateway local deve tratar seu helper SSH como código privilegiado, mesmo que ele não tenha um cofre e não mantenha estado. O helper recebe um comando, herda descritores de arquivo e o ambiente, e pode se tornar o processo que conversa com o host remoto. Se um invasor puder substituir esse executável, todas as aprovações feitas acima dele perdem o efeito.
O desenho seguro tem duas tarefas diferentes. As verificações preliminares detectam um bundle danificado ou mal instalado antes de qualquer solicitação chegar ao executor. Um requisito de código aplicado na inicialização pede ao macOS que recuse a imagem real do processo se sua identidade de assinatura estiver errada. Confundir essas tarefas cria a conhecida lacuna em que o aplicativo verifica um arquivo e executa outro.
Derive o helper do bundle em execução
Resolva o helper a partir do bundle do aplicativo que está realmente em execução, nunca de PATH, do diretório atual, de uma preferência ou de um caminho fornecido pelo agente. O local esperado deve ser uma constante de compilação. Um gateway que procura sp-ssh já permitiu que seu ambiente escolhesse código executável.
A Apple documenta locais padrão para código aninhado no bundle, inclusive Contents/MacOS e Contents/Helpers. Escolha um local, coloque somente código nele e faça a compilação falhar se o helper terminar em outro lugar. Assine primeiro o helper e por último o aplicativo externo. Essa ordem permite que a assinatura externa sele a referência ao código aninhado.
O Foundation pode localizar um executável auxiliar, mas a decisão de segurança ainda precisa verificar a relação exata dele com o bundle principal. Resolva links simbólicos, padronize as duas URLs e compare componentes do caminho, não prefixos de texto. Um teste como candidate.path.hasPrefix(bundle.path) aceita vizinhos como /Applications/Good.app.backup e pode falhar com maiúsculas, minúsculas ou normalização. O candidato deve ser exatamente a única URL esperada dentro do bundle em execução.
Não aceite como alternativa um helper copiado ao lado do aplicativo. Essa saída é tentadora durante o desenvolvimento porque esconde um empacotamento quebrado. Em produção, ela muda silenciosamente a fronteira de confiança do conteúdo assinado para qualquer arquivo que ocupe um caminho próximo. Builds de desenvolvimento devem usar uma configuração explícita e falhar claramente quando o empacotamento estiver errado.
O local comprova algo sobre o empacotamento, não sobre a identidade. Um invasor capaz de substituir um arquivo dentro de um bundle gravável pode manter o mesmo caminho. Por isso, as próximas verificações inspecionam o objeto aberto e sua assinatura.
Abra primeiro e depois inspecione a identidade
Abra o candidato com O_NOFOLLOW, mantenha o descritor aberto e chame fstat nesse descritor. A ordem importa. Chamar lstat, conferir o resultado e depois chamar open dá a outro processo a chance de substituir a entrada do diretório entre as operações.
O Secure Coding Guide da Apple recomenda operações baseadas em descritores por esse motivo. Ele pede especificamente a verificação de tipo, UID, GID, modo e número de links depois da abertura. Para um helper executável, uso uma verificação preliminar com este formato:
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
int inspect_helper(const char *path, uid_t expected_uid, struct stat *snapshot) {
int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) return -1;
struct stat st;
if (fstat(fd, &st) != 0 ||
!S_ISREG(st.st_mode) ||
st.st_uid != expected_uid ||
(st.st_mode & (S_IWGRP | S_IWOTH)) != 0 ||
(st.st_mode & S_IXUSR) == 0 ||
st.st_nlink != 1) {
int saved = errno ? errno : EPERM;
close(fd);
errno = saved;
return -1;
}
*snapshot = st;
return fd;
}
expected_uid vem do modelo de instalação, não do próprio arquivo. Uma instalação administrada pelo sistema pode exigir propriedade de root. Um aplicativo por usuário pode pertencer legitimamente àquele usuário. Não exija UID 0 apenas porque root parece confiável. A Apple também alerta que um caminho pode atravessar para outro sistema de arquivos montado, onde a propriedade isolada diz menos do que muitos desenvolvedores supõem.
A regra do número de links merece uma decisão consciente. Um executável comum dentro de um bundle normalmente tem um único link físico, então rejeitar outra quantidade é razoável. Se o sistema de empacotamento cria links físicos de propósito, documente isso e teste o número esperado. Remover a verificação porque um build surpreendeu você deixa outro nome pelo qual o mesmo inode pode ser alterado.
Mantenha o descritor e a captura de stat até a inicialização terminar. Eles não tornam atômico um spawn baseado em caminho, mas permitem detectar substituições, registrar o dispositivo e o inode envolvidos e explicar uma recusa sem reabrir um nome controlado pelo invasor.
Verifique cada diretório gravável da cadeia
Um modo perfeito no helper não o protege se seu diretório pai puder ser renomeado ou modificado. O invasor não precisa editar os bytes do executável quando pode substituir a entrada de diretório que aponta para eles.
Percorra do pai do helper até a raiz do bundle usando descritores de diretório. Em cada componente, recuse links simbólicos, confirme que seja um diretório, registre dispositivo e inode e aplique a regra de propriedade e gravação do modelo de instalação. openat e fstatat com um descritor do pai são melhores do que resolver repetidamente strings absolutas. O percurso deve terminar na raiz já verificada, não em um caminho que apenas tenha um sufixo conhecido.
Aqui é preciso falar com honestidade sobre instalações por usuário. Se o mesmo usuário que executa o gateway possui um bundle gravável, outro processo dessa conta pode substituir os arquivos. As verificações de modo e proprietário revelam exposição acidental a outras contas, mas não defendem contra o comprometimento total da conta atual. A identidade da assinatura ainda importa, porque um invasor não satisfaz seu requisito designado simplesmente aplicando uma assinatura ad hoc.
Compare também IDs de dispositivo se a política exigir um bundle local e em um só volume. A Apple observa que um nome de caminho pode cruzar um limite de montagem. Recusar limites inesperados é útil, mas não prova que um volume seja confiável. Prova apenas que o objeto não está onde o contrato de empacotamento dizia que estaria.
Essas verificações encontram falhas de implantação bastante comuns: um atualizador que deixa um diretório temporário gravável pelo grupo, um helper restaurado fora do aplicativo assinado ou um link simbólico criado por um script de desenvolvimento. Elas devem interromper o executor antes que ele receba um comando. Continuar com um aviso transforma um erro de empacotamento em lógica de seleção de executável.
Verifique a identidade, não só a validade
Uma assinatura válida responde se o código ainda é consistente com essa assinatura. Ela não diz que sua equipe assinou o código nem que o executável é seu helper. No macOS, código pode ter assinatura ad hoc, e outro desenvolvedor pode produzir código com assinatura perfeitamente válida.
Use Code Signing Services com um requisito explícito que nomeie o identificador de assinatura e a identidade da equipe esperados para o canal de distribuição. A Apple TN3127 esclarece a diferença: o identificador é um nome escolhido pelo signatário, a identidade contém certificado e chave privada, e o requisito designado expressa o que conta como o mesmo código entre versões. Comparar a string Authority exibida é um atalho de diagnóstico, não uma fronteira de segurança do produto.
Crie um SecStaticCode para a URL absoluta do helper, compile ou carregue o requisito e chame SecStaticCodeCheckValidityWithErrors. Inclua kSecCSStrictValidate e kSecCSCheckAllArchitectures. A Apple documenta que o padrão pode validar apenas a arquitetura nativa de um binário universal. Validar todas impede que uma arquitetura não testada carregue uma assinatura diferente ou danificada.
Não use kSecCSBasicValidateOnly para essa tarefa. A opção pula a validação do executável principal e dos recursos, anulando a verificação de integridade. Também não confie no requisito designado do próprio helper sem compará-lo com um requisito controlado pelo gateway. Autodescrição não é autorização.
O aplicativo externo também deve ser validado. A Apple define locais padrão de código aninhado para que o sistema de assinatura trate helpers como código. Uma versão assinada corretamente tem uma cadeia de intenção: o helper satisfaz seu requisito esperado e o selo do aplicativo registra o componente aninhado. Verificar os dois detecta um helper válido transplantado para um bundle danificado.
Preserve o CFError detalhado internamente e converta-o em poucas categorias de recusa para o usuário: assinatura ausente, requisito incompatível, recurso inválido, arquitetura não suportada ou arquivo alterado. Nunca transforme um erro em nova tentativa por outro caminho. Falha de assinatura significa que o executor está indisponível.
Uma verificação estática não fecha a corrida
A validação estática só vale enquanto o arquivo não muda. A documentação da Apple para SecStaticCodeCheckValidity diz isso diretamente e cita sistemas de arquivos dinâmicos de rede, union e FUSE. O alerta também vale para uma entrada comum substituível: depois do retorno, outro processo pode renomear outro executável sobre o caminho antes que posix_spawn o resolva.
A falha ocorre em quatro movimentos:
- O gateway resolve
/Applications/Example.app/Contents/Helpers/runnere valida o arquivo A. - Um processo concorrente renomeia o arquivo B sobre esse caminho.
- O gateway pede a
Processouposix_spawnque execute o caminho. - O kernel abre B, pois a chamada recebeu um nome e não o descritor mantido para A.
Comparar stat antes e depois da validação reduz a janela e detecta muitas tentativas, mas não torna os passos dois e três indivisíveis. Calcular seu próprio hash tem a mesma limitação e repete o trabalho da assinatura. Um arquivo de trava coordena somente processos que aceitam respeitá-lo. Um invasor não aceitará.
Uma recomendação comum é validar uma vez na abertura do aplicativo e armazenar o sucesso. Ela é popular porque a verificação tem custo e o código do bundle parece imutável durante o uso normal. Para um gateway, está errada. Atualizações, restaurações, mudanças de volume e substituições deliberadas podem ocorrer enquanto um aplicativo de barra de menus continua aberto. Armazene o objeto de requisito compilado se necessário, como sugere a Apple, mas reavalie o executável a cada lançamento.
Um descritor aberto ainda ajuda. Mantenha-o durante a validação, faça outro fstat imediatamente antes do spawn e recuse mudanças de dispositivo, inode, tamanho, hora de modificação ou de alteração de metadados. Tire uma terceira captura depois da criação para telemetria. Isso melhora o diagnóstico e aumenta o esforço de ataque, mas a afirmação deve ser precisa: somente um requisito aplicado pelo sistema na inicialização vincula identidade e criação do processo.
Vincule o requisito à criação do processo
Em sistemas com LightweightCodeRequirements, defina Process.launchRequirement antes de chamar run. A Apple afirma que, se o executável não satisfizer o LaunchCodeRequirement, o sistema não executa o processo e cria um relatório de falha. A verificação decisiva passa a fazer parte da criação, quando a seleção do caminho não pode mais se afastar da decisão.
A configuração principal em Swift é curta:
import Foundation
import LightweightCodeRequirements
func configuredProcess(helper: URL, team: String, identifier: String) throws -> Process {
let requirement = try LaunchCodeRequirement.allOf {
ValidationCategory(.developerID)
TeamIdentifier(team)
SigningIdentifier(identifier)
}
let process = Process()
process.executableURL = helper
process.launchRequirement = requirement
return process
}
Equipe e identificador devem vir da configuração de lançamento compilada no gateway assinado. Não os carregue de preferências ao lado do helper. Se houver mais de um canal de assinatura, crie e teste um requisito explícito para cada canal suportado, em vez de enfraquecer uma expressão até que todos os builds passem.
Aplique o requisito a um helper Mach-O, não a um script de shell. A Apple observa que, em um script com shebang, o requisito avalia o interpretador. Provar que /bin/bash é código da Apple não diz nada sobre os bytes do script que você pretende confiar. Coloque a lógica em código executável assinado ou trate o script como dados selados consumidos por código confiável, sem lançá-lo como executor privilegiado.
Ative a API conforme a disponibilidade do SDK e mantenha a verificação estática para diagnóstico. Em alvos antigos, POSIX_SPAWN_START_SUSPENDED pode criar o filho suspenso antes do código de espaço do usuário. Obtenha um SecCode dinâmico pelo PID, valide-o e depois retome ou mate o processo. Essa alternativa é delicada: verifique cada retorno, evite confusão de PID, feche descritores inesperados e não envie comandos ou credenciais antes do sucesso. Se o modelo de ameaça não aceitar essa complexidade, exija um sistema com suporte a requisitos no lançamento.
Inicie com um contrato de processo estreito
Verificar o binário não higieniza o que você entrega a ele. Construa argumentos com campos estruturados, defina um ambiente explícito, escolha o diretório de trabalho conscientemente e passe somente os descritores necessários. Nunca invoque um shell para compor um comando SSH.
Comece com ambiente vazio ou lista permitida. Variáveis que influenciam carregamento dinâmico, descoberta de configuração, idioma, proxies ou busca do diretório pessoal podem mudar o comportamento de um programa assinado sem alterar seu código. Hardened Runtime e Library Validation reduzem alguns ataques, mas não tornam seguro um ambiente herdado arbitrário.
Trate entrada e saída padrão como protocolo. Defina tamanhos máximos, recuse campos extras, imponha prazo e diferencie erro de protocolo de status de saída SSH. Um helper sem estado não deve ler chaves de variáveis, argumentos, arquivos temporários ou caminhos do agente. Deve receber a solicitação mínima por um canal controlado e devolver o resultado mínimo.
Feche todos os descritores sem relação. O_CLOEXEC ajuda nos arquivos da verificação, mas audite também o restante do aplicativo. Um filho que herda o descritor do banco do cofre, um listener IPC ou um arquivo de log recebe acesso que a verificação nunca pretendeu conceder. Defina limites de recursos quando o contrato permitir e termine todo o grupo de processos em cancelamento ou expiração.
Registre as entradas da decisão sem segredos: local relativo no bundle, versão do requisito, capturas de dispositivo e inode, resultado, PID do filho e motivo de término. O registro deve dizer qual lançamento foi autorizado. Guardar somente o caminho textual perde o fato de que um nome pode ter apontado para vários arquivos durante um incidente.
Coloque a verificação dentro da autorização
A aceitação do helper pertence à mesma transação que autoriza a ação e deve terminar antes de o gateway liberar qualquer capacidade ao filho. Verificar na abertura do aplicativo é cedo demais. Verificar depois da aprovação humana é tarde demais se a falha puder vazar o comando, abrir um socket ou acionar uma alternativa.
Modele um lançamento como máquina de estados com limites explícitos e irreversíveis. O gateway recebe e analisa a ação enquanto a solicitação não tem acesso a credenciais. Confirma a disponibilidade do cofre, resolve e inspeciona o helper, obtém a autorização humana e lança com o requisito anexado. Só depois do sucesso cria o canal mínimo de credencial ou conexão. Qualquer falha anterior destrói a ação pendente.
A posição da aprovação depende de seu significado. Se o cartão pergunta se um agente pode executar um comando SSH específico, pode aparecer antes do lançamento caro. O comando aprovado deve ficar imutável e uma falha de validação deve consumir ou cancelar a aprovação, nunca reservá-la para outro binário. Se a aprovação expressa confiança no processo executor, mostre-a somente após conhecer a identidade candidata. Os dois desenhos podem funcionar, mas o registro deve unir resumo da solicitação, decisão, requisito e processo em uma tentativa.
Não exponha um segredo só porque a criação retornou sucesso. Prepare pipes ou sockets antes se a API exigir, mas mantenha a ponta com credencial fechada ou bloqueada. Aguarde o sucesso da exigência de lançamento, registre a identidade e então envie a solicitação. Se o filho precisar de uma operação de chave SSH, prefira uma interface limitada de assinatura ou conexão a copiar bytes privados para sua memória. Quanto menos autoridade ele receber, menor o custo de um erro anterior.
O cancelamento exige o mesmo cuidado. O usuário pode revogar uma sessão durante a validação, ou o agente se desconectar entre aprovação e spawn. Verifique a geração da autorização logo antes de lançar e novamente antes de liberar a solicitação. Se mudar, termine o filho e feche os canais. Não é uma corrida de filesystem, mas é outra lacuna entre verificação e uso da mesma decisão.
Lançamentos concorrentes não devem compartilhar configuração mutável. Cada tentativa precisa de sua URL, requisito, argumentos, ambiente, descritores, prazo e identificador de auditoria imutáveis. Um modelo global de Process alterado por uma thread enquanto outra chama run pode enviar uma solicitação ao executável ou ambiente errado mesmo que ambos os binários sejam válidos. Sincronize a transição curta que consome a aprovação e inicia o processo, não toda a vida da conexão.
Um bom invariante é que nenhum byte controlado pelo filho entre no estado privilegiado antes da imposição da identidade. O helper pode escrever em um pipe assim que começa; mantenha o parser desconectado até a aceitação, limite a exposição do buffer e trate saída antecipada como violação. Também não use texto de erro de um filho não verificado para construir caminhos privilegiados, escolher credenciais ou selecionar outro binário.
Essa ordem produz um diário mais claro. Uma tentativa pode mostrar request_received, candidate_preflight_passed, authorization_granted, launch_requirement_passed, request_released e o resultado final. Transições ausentes ficam visíveis. Um registro que salta da aprovação para o código de saída não informa se o executor esperado recebeu o comando.
Verifique o filho que realmente iniciou
A exigência no lançamento deve ser a barreira decisiva, mas a observação posterior detecta erros de integração e fornece uma identidade estável. Quando run tiver sucesso, colete o PID e obtenha um SecCode dinâmico do processo. Verifique-o com o requisito correspondente antes de conectar o parser ou liberar dados sensíveis.
A distinção da Apple entre SecStaticCode e SecCode ajuda aqui. Um objeto estático descreve o código no disco e não está ligado de forma inerente ao código em execução. Um objeto dinâmico representa o código carregado no processo. Eles respondem perguntas diferentes: o controle estático explica a integridade do candidato instalado; o dinâmico confirma a identidade atribuída pelo macOS ao filho atual.
Buscar apenas por PID tem riscos. PIDs são reutilizados e um filho breve pode sair entre run, busca e validação. Não conseguir obter ou validar o objeto deve ser falha. Quando uma API IPC oferece token de auditoria, use essa referência mais forte em vez de um PID informado numa mensagem. Nunca confie no PID, identificador ou caminho declarado pelo filho.
O controle posterior não deve ser o único numa plataforma em que o filho ganha CPU antes. Um substituto pode agir entre spawn e inspeção. Iniciar suspenso antes do código do usuário reduz a lacuna em sistemas antigos, mas a implementação deve retomar somente o PID validado e matá-lo em qualquer erro. O requisito de lançamento é mais simples, pois o sistema recusa a divergência antes da execução.
Depois da aceitação, mantenha um handle do processo e vincule cada mensagem a essa instância. Não reabra um socket nomeado nem reconecte a um endpoint que outro processo possa ocupar. Ao sair, feche canais, invalide a ação e exija um novo lançamento verificado. O primeiro filho válido não autoriza outro que reutilize seu PID ou nome.
Registre evidências instaladas e em execução. Para o objeto: caminho relativo, dispositivo, inode, tamanho, datas e resultado estático. Para o processo: PID, revisão do requisito, resultado imposto ao lançar, validação dinâmica, início e status final. Esses valores não são segredos e distinguem versão corrompida, colisão de atualização, erro de configuração e substituição sem expor comandos ou credenciais.
A identidade também precisa de uma regra de vida. Se o helper executar outro programa, a identidade validada não passa à nova imagem. Evite um wrapper assinado que passa na verificação e chama um ssh arbitrário por PATH. Se houver transição exec, o executável final precisa de caminho fixo e requisito próprio, ou o helper confiável deve implementar o protocolo. A assinatura do lançador não prova qual programa ele escolherá depois.
Observe também bibliotecas e configuração. O executável principal pode satisfazer o requisito enquanto variáveis inseguras ou locais graváveis mudam seu comportamento. Assine dependências, ative opções adequadas de Hardened Runtime, use Library Validation quando compatível e remova caminhos de busca do ambiente. A configuração deve ser entrada validada pelo gateway, não arquivos descobertos pelo filho no diretório pessoal.
Por fim, teste o que a telemetria afirma. Num teste de substituição, a captura deve identificar A, a exigência deve lançar A ou recusar B e a validação dinâmica deve concordar. Se o log disser que A passou enquanto B não registrado executou, o modelo ainda segue o nome, não o processo.
Falhe de forma fechada sem impedir atualizações
Qualquer falha deve parar esse executor e deixar o resto do aplicativo em estado compreensível. Não use o ssh do sistema como alternativa, não procure em outro diretório, não remova o requisito nem peça ao usuário que aprove um helper sem identidade. Aprovação não corrige a identidade de um executável.
Atualizações precisam de uma transição separada. Pare de aceitar novo trabalho SSH, deixe filhos ativos terminar ou encerre-os, instale o bundle assinado completo por substituição atômica e repita todos os controles. Um helper e um aplicativo de versões diferentes podem ter assinaturas válidas e romper seu contrato de protocolo. Adicione uma troca de versão depois do lançamento e recuse divergências antes de enviar a ação.
Decida quais mudanças exigem recuperação visível. Um helper ausente após atualização parcial pede reinstalação. Equipe ou identificador diferentes podem indicar adulteração e merecem mensagem mais forte. Um modo diferente pode vir de backup. Preserve o erro exato internamente e ofereça uma ação curta que não ensine a contornar o controle.
Teste substituições, não só assinaturas corretas. A suíte deve cobrir link simbólico no caminho, segundo link físico, pais graváveis por grupo, substituto com assinatura ad hoc, binário bem assinado com identificador errado, arquitetura não nativa danificada, troca entre preflight e lançamento e atualização com o app aberto. Um hook que pausa após validar torna a corrida reproduzível.
O Sallyport usa um helper Go incluído e sem estado em seu canal SSH, enquanto o núcleo do cofre permanece dentro do aplicativo assinado da barra de menus. Essa separação mantém segredos fora do helper, mas não torna sua verificação opcional: o gateway ainda precisa provar qual executor iniciou antes de entregar uma ação aprovada.
A regra de aceitação deve caber em uma linha de revisão: o arquivo esperado no bundle em execução passa pelos controles de descritor e diretórios, satisfaz o requisito de assinatura da versão e a criação do processo impõe a mesma identidade. Se a plataforma não puder impor o último ponto, documente a garantia mais fraca e reduza o que o filho recebe.
FAQ
Por que verificar apenas o caminho do helper SSH não basta?
Um caminho é um nome, não uma identidade estável. Outro processo pode substituir sua entrada após a verificação, então o gateway precisa inspecionar o arquivo aberto e impor um requisito ao lançar.
Um aplicativo macOS deve procurar o helper em PATH?
Não. PATH deixa o ambiente escolher código e faz um empacotamento quebrado parecer uma descoberta válida. Derive uma URL exata do bundle em execução e recuse alternativas.
Quais metadados de arquivo o gateway deve verificar?
Depois de abrir com O_NOFOLLOW, use fstat para confirmar tipo, proprietário, permissões e número de links. Guarde dispositivo e inode para comparação e registro.
Uma assinatura válida prova que o helper é meu?
Não. Ela prova consistência com uma assinatura, e o macOS pode executar código de outros desenvolvedores ou com assinatura ad hoc. Aplique requisito para identificador, equipe e categoria esperados.
Por que validar todas as arquiteturas de um helper universal?
A Apple diz que a validação estática costuma verificar só a arquitetura nativa. kSecCSCheckAllArchitectures inspeciona cada slice, inclusive uma que outra máquina possa executar.
Um descritor aberto elimina a corrida de substituição?
Ele estabiliza o arquivo inspecionado, mas Process e posix_spawn ainda escolhem pelo caminho. Use-o para capturas e diagnóstico, depois vincule identidade e criação com requisito de lançamento.
O que Process.launchRequirement acrescenta?
Ele pede ao sistema que avalie um LaunchCodeRequirement durante a inicialização. Se falhar, o processo não executa, fechando a lacuna do controle estático.
Propriedade e permissões ainda ajudam com assinatura?
Sim, pois detectam instalações inseguras e reduzem oportunidades de troca. São evidências auxiliares e não substituem a identidade de assinatura nem a imposição no lançamento.
O aplicativo deve armazenar uma verificação bem-sucedida?
Pode armazenar o requisito compilado se as medições justificarem, mas deve validar em cada lançamento. Um app duradouro pode atravessar atualização, restauração ou substituição.
Como o gateway deve reagir à falha de verificação?
Recuse esse executor, registre o motivo exato e ofereça recuperação adequada, como reinstalar um app danificado. Nunca procure outro binário SSH nem enfraqueça o requisito.