Na parte anterior, apenas mergulhamos os pés nas águas do espaço para nome e ao mesmo tempo vimos como era fácil iniciar o processo em um espaço para nome UTS isolado. Nesta postagem, abordaremos o namespace do usuário.
Entre outros recursos relacionados à segurança, os namespaces de usuário isolam os identificadores de usuários e grupos no sistema. Nesta postagem, focaremos apenas os recursos de identificação de usuário e grupo (UID e GID, respectivamente), uma vez que eles desempenham um papel fundamental na realização de verificações de permissões e outras atividades relacionadas à segurança em todo o sistema.
No Linux, esses IDs são simplesmente números inteiros que identificam usuários e grupos no sistema. E alguns deles são atribuídos a cada processo para definir a quais operações / recursos esse processo pode e não pode ter acesso. A capacidade de um processo causar danos depende das permissões associadas aos IDs atribuídos.
Namespaces de usuário
Ilustraremos os recursos dos namespaces de usuário usando apenas IDs de usuário. Exatamente as mesmas ações se aplicam aos IDs de grupo, que abordaremos mais adiante nesta postagem.
O espaço para nome do usuário possui sua própria cópia dos identificadores de usuário e grupo. O isolamento permite associar o processo a outro conjunto de IDs, dependendo do espaço de nome do usuário ao qual ele pertence atualmente. Por exemplo, o processo $pid
pode ser executado a partir da root
(UID 0) no espaço de nome do usuário P e repentinamente continua a ser executado no proxy
(UID 13) após alternar para outro espaço de nome do usuário Q.
Os espaços do usuário podem ser aninhados! Isso significa que uma instância de um espaço para nome personalizado (pai) pode ter zero ou mais espaços para nome filho, e cada espaço para nome filho, por sua vez, pode ter seus próprios espaços filho e assim por diante ... (até atingir o limite de 32 níveis de aninhamento). Quando um novo espaço para nome C é criado, o Linux define o espaço para nome do usuário atual do processo P, criando C como pai de C, e isso não pode ser alterado posteriormente. Como resultado, todos os espaços de nome de usuário têm exatamente um pai, formando uma estrutura de árvore de espaços de nome. E, como no caso de árvores, uma exceção a essa regra está no topo, onde temos o espaço para nome raiz (ou inicial, padrão). Isso, se você ainda não está fazendo algum tipo de mágica de contêiner, é provavelmente o espaço para nome do usuário ao qual todos os seus processos pertencem, pois esse é o único espaço para nome do usuário desde que o sistema foi iniciado.
Nesta postagem, usaremos os prompts de comando P $ e C $ para indicar o shell que está sendo executado no momento no namespace de usuário pai P e filho C, respectivamente.
Mapeamentos de ID do Usuário
O espaço para nome do usuário, de fato, contém um conjunto de identificadores e algumas informações que conectam esses IDs com um conjunto de IDs de outro espaço para nome do usuário - esse dueto define uma idéia completa dos IDs dos processos disponíveis no sistema. Vamos ver como isso pode parecer:
P$ whoami iffy P$ id uid=1000(iffy) gid=1000(iffy)
Em outra janela do terminal, vamos iniciar o shell usando o unshare
(o sinalizador -U
cria um processo no novo espaço de nome do usuário):
P$ whoami iffy P$ unshare -U bash
Espere um minuto, quem? Agora que estamos em um shell aninhado em C , o usuário atual se torna ninguém? Podemos adivinhar que, como C é um novo espaço para nome de usuário, o processo pode ter um tipo diferente de ID. Portanto, provavelmente não esperávamos que ele permanecesse iffy
, mas nobody
é engraçado. Por outro lado, é ótimo porque conseguimos o isolamento que queríamos. Nosso processo agora tem uma substituição de ID diferente (embora quebrada) no sistema - atualmente vê todos como nobody
e cada grupo como nogroup
.
As informações que vinculam um UID de um espaço para nome de usuário a outro são chamadas de mapeamento de ID do usuário . É uma tabela de pesquisa para identificações correspondentes no namespace de usuário atual para identificações em outro namespace e cada namespace de usuário está associado a exatamente um mapeamento de UID (além de outro mapeamento de GID para o ID de grupo).
Esse mapeamento é o que está quebrado em nosso shell de unshare
. Acontece que os novos espaços de nome de usuário começam com o mapeamento vazio e, como resultado, o Linux usa o usuário horrível nobody
por padrão. Precisamos corrigir isso antes de podermos realizar qualquer trabalho útil em nosso novo espaço para nome. Por exemplo, atualmente, as chamadas do sistema (como setuid
) que tentam trabalhar com o UID falharão. Mas não tenha medo! Fiel à tradição de arquivos , o Linux apresenta esse mapeamento usando o sistema de arquivos /proc
em /proc/$pid/uid_map
(em /proc/$pid/gid_map
para o GID), onde $pid
é o ID do processo. Vamos chamar esses dois arquivos de arquivos de mapeamento.
Arquivos de mapa
Arquivos de mapa são arquivos especiais no sistema. O que são especiais? Bem, retornando conteúdos diferentes cada vez que você os lê, dependendo do que seu processo está lendo. Por exemplo, o arquivo de mapa /proc/$pid/uid_maps
retorna o mapeamento de UIDs do espaço de nome do usuário que possui o processo $pid
para UIDs no espaço de nome do usuário do processo de leitura. E, como resultado, o conteúdo retornado ao processo X pode diferir do que retornou ao processo Y , mesmo se eles lerem o mesmo arquivo de mapa ao mesmo tempo.
Em particular, o processo X , que lê o arquivo de mapa UID /proc/$pid/uid_map
, recebe um conjunto de strings. Cada linha mapeia um intervalo contínuo de UIDs para o espaço de nome do usuário C do processo $pid
, correspondendo a um intervalo de UIDs em outro espaço de nome.
Cada linha tem o formato $fromID $toID $length
, em que:
$fromID
é o UID inicial do intervalo para o namespace do usuário do processo $pid
$lenght
é o comprimento do intervalo.- A tradução de
$toID
depende do processo de leitura X. Se X pertencer a outro espaço de nome de usuário U , $toID
é o UID inicial do intervalo em U que mapeia a partir de $fromID
. Caso contrário, $toID
é o UID inicial do intervalo em P , o namespace do usuário pai do processo C.
Por exemplo, se um processo lê o arquivo /proc/1409/uid_map
e, entre as linhas recebidas, você pode ver 15 22 5
, os UIDs de 15 a 19 no espaço de nome de usuário do processo 1409
mapeados para os UIDs 22-26 de um espaço de nome de usuário separado do processo de leitura.
Por outro lado, se um processo lê do arquivo /proc/$$/uid_map
(ou um arquivo de mapa de qualquer processo pertencente ao mesmo espaço de nome de usuário que o processo de leitura) e recebe 15 22 5
, então UIDs de 15 a 19 em o namespace C do usuário é mapeado nos UIDs de 22 a 26 do pai para o namespace do usuário C.
Vamos tentar:
P$ echo $$ 1442
Bem, isso não foi muito emocionante, pois foram dois casos extremos, mas isso diz algumas coisas:
- O espaço para nome do usuário recém-criado realmente terá arquivos de mapas vazios.
- O UID 4294967295 não é mapeável e inadequado para uso mesmo no espaço para nome do usuário
root
. O Linux usa esse UID especificamente para indicar a ausência de um ID do usuário .
Gravando arquivos de mapa UID
Para corrigir nosso recém-criado espaço para nome de usuário C , precisamos fornecer nossos mapeamentos necessários, escrevendo seu conteúdo para mapear arquivos para qualquer processo que pertença a C (não podemos atualizar esse arquivo depois de gravá-lo). Escrever neste arquivo informa ao Linux duas coisas:
- Quais UIDs estão disponíveis para processos relacionados ao espaço de nomes de usuário C. de destino
- Quais UIDs no namespace de usuário atual correspondem aos UIDs em C.
Por exemplo, se escrevermos o seguinte no espaço de nome do usuário pai P no arquivo de mapa do espaço de nome filho C :
0 1000 1 3 0 1
essencialmente dizemos ao Linux que:
- Para processos em C , os únicos UIDs existentes no sistema são os UIDs
0
e 3
. Por exemplo, a chamada do sistema setuid(9)
sempre termina com algo como um ID de usuário inválido . - UIDs
1000
e 0
em P correspondem aos UIDs 0
e 3
em C. Por exemplo, se um processo em execução com o UID 1000
em P alternar para C , ele descobrirá que, após a troca, seu UID se tornará root
0
.
Proprietário de espaço para nome e privilégio
Em uma postagem anterior, mencionamos que, ao criar novos espaços para nome, o acesso com nível de superusuário é necessário. Os espaços para nome do usuário não impõem esse requisito. De fato, outro recurso é que eles podem possuir outros namespaces.
Sempre que um espaço para nome não-usuário N é criado , o Linux atribui o espaço para nome do usuário atual P do processo que cria N ao proprietário do espaço para nome N. Se P for criado junto com outros espaços para nome na mesma chamada de sistema clone
, o Linux garantirá que P seja criado primeiro e se torne o proprietário de outros espaços para nome.
O proprietário dos espaços para nome é importante porque um processo que solicita uma ação privilegiada em um recurso que não seja um espaço para nome do usuário terá seus privilégios de UID verificados no proprietário desse espaço para nome do usuário e não no espaço para nome raiz do usuário. Por exemplo, digamos que P é o namespace do usuário pai do filho C , e P e C possuem seu próprio namespace de rede M e N, respectivamente. Um processo pode não ter privilégios para criar os dispositivos de rede incluídos no M , mas pode fazê-lo para N.
A conseqüência de ter um proprietário de namespace para nós é que podemos descartar o requisito sudo
ao executar comandos usando o unshare
ou o isolate
se solicitarmos também a criação de um namespace de usuário. Por exemplo, unshare -u bash
exigirá sudo
, mas a opção unshare -Uu bash
não será mais:
Infelizmente, reaplicaremos o requisito de superusuário na próxima postagem, pois o isolate
precisa de privilégios de root
no espaço para nome do usuário raiz para configurar corretamente o espaço para nome Mount e Network. Mas, certamente, removeremos os privilégios do processo da equipe para garantir que a equipe não tenha permissões desnecessárias.
Como os IDs são resolvidos
Acabamos de ver um processo em execução como um usuário comum 1000
repente mudado para o root
. Não se preocupe, não houve aumento de privilégios. Lembre-se de que este é apenas um ID de mapeamento : enquanto nosso processo pensa que é o root
no sistema, o Linux sabe que root
- no seu caso - significa o UID 1000
habitual (graças ao nosso mapeamento). Portanto, em um momento em que os namespaces pertencentes a seu novo namespace de usuário (como namespace de rede em C ) reconhecem seus direitos como root
, outros (como namespace de rede em P ) não. Portanto, o processo não pode fazer nada que o usuário 1000
não consiga.
Sempre que um processo em um espaço de nome de usuário aninhado executa uma operação que requer verificação de permissão - por exemplo, criando um arquivo - seu UID nesse espaço de nome de usuário é comparado com o ID do usuário equivalente no espaço de nome do usuário raiz, passando os mapeamentos na árvore do espaço de nome para a raiz. Há um movimento na direção oposta, por exemplo, quando ele lê IDs de usuário, como fazemos com ls -l my_file
. O UID do proprietário my_file
mapeado do espaço de nome do usuário raiz para o atual e o ID correspondente final (ou ninguém, se o mapeamento estiver ausente em algum lugar da árvore inteira) é fornecido ao processo de leitura.
ID do grupo
Mesmo se estivéssemos enraizados em C , ainda estamos associados ao terrível nogroup
como nosso ID de grupo. Nós apenas precisamos fazer o mesmo para o correspondente /proc/$pid/gid_map
. Antes de podermos fazer isso, precisamos desativar a chamada do sistema setgroups
(isso não é necessário se nosso usuário já tiver um recurso CAP_SETGID
em P , mas não assumiremos isso, pois isso geralmente vem com privilégios de superusuário) escrevendo "deny "para o arquivo proc/$pid/setgroups
:
Implementação
O código fonte desta publicação pode ser encontrado aqui .
Como você pode ver, há muitas dificuldades associadas ao gerenciamento de namespaces de usuário, mas a implementação é bastante simples. Tudo o que precisamos fazer é escrever um monte de linhas em um arquivo - era triste descobrir o que e onde escrever. Sem mais delongas, eis nossos objetivos:
- Clone um processo de equipe em seu próprio espaço para nome de usuário.
- Escreva nos arquivos de mapa UID e GID do processo de equipe.
- Redefina todos os privilégios de superusuário antes de executar o comando.
1
alcançado simplesmente adicionando o sinalizador CLONE_NEWUSER
à nossa chamada do sistema de clone
.
int clone_flags = SIGCHLD | CLONE_NEWUTS | CLONE_NEWUSER;
Para 2
adicionamos a função prepare_user_ns
, que representa cuidadosamente um usuário comum 1000
como root
.
static void prepare_userns(int pid) { char path[100]; char line[100]; int uid = 1000; sprintf(path, "/proc/%d/uid_map", pid); sprintf(line, "0 %d 1\n", uid); write_file(path, line); sprintf(path, "/proc/%d/setgroups", pid); sprintf(line, "deny"); write_file(path, line); sprintf(path, "/proc/%d/gid_map", pid); sprintf(line, "0 %d 1\n", uid); write_file(path, line); }
E vamos chamá-lo do processo principal no espaço de nomes do usuário pai antes de sinalizar o processo de comando.
...
Para a etapa 3
atualizamos a função cmd_exec
para garantir que o comando seja executado a partir do usuário não privilegiado usual 1000
que fornecemos no mapeamento (lembre-se de que o usuário raiz 0
no espaço para nome do usuário do processo da equipe é o usuário 1000
):
...
E isso é tudo! isolate
agora inicia o processo em um namespace de usuário isolado.
$ ./isolate sh ===========sh============ $ id uid=0(root) gid=0(root)
Havia alguns detalhes nesta postagem sobre como os espaços de nome de usuário funcionam, mas no final, a configuração da instância foi relativamente simples. Na próxima postagem, veremos a possibilidade de executar um comando em nosso próprio espaço para nome Mount usando isolate
(revelando o segredo por trás da instrução FROM
do Dockerfile
). Lá precisaremos ajudar um pouco mais o Linux para configurar corretamente a instância.