Jump to content
  • Como são feitos os keygens

       (0 reviews)

    Keygen, que abrevia “Key Generator” é um software capaz de gerar chaves válidas de registro para um software protegido. A prática desta técnica pode (e provavelmente vai) infringir algumas leis quando usada em softwares comerciais. No entanto, existem alguns desafios na internet chamados de “keygenmes”, que são programas feitos justamente para serem quebrados. O desafio está em criar keygens para eles. Este é um estudo muito interessante que treina bastante a lógica, matemática, assembly e até mesmo massageia o ego, se você vencer.

    Obviamente você não tem acesso ao código-fonte do desafio, então um disassembler, software capaz de interpretar os bytes do binário como mnemônicos assembly, precisará ser usado. Para facilitar o keygenning, também é interessante usar um debugger.

    Neste artigo eu vou usar um poderoso debugger e disassembler multiplataforma chamado EDB, projeto este que apoio. Ele foi escrito em C++ e Qt (então você precisa ter a Qt instalada para rodar). No github do projeto há instruções para instalação no Debian/Ubuntu, Fedora, Gentoo e para compilação (em qualquer ambiente).

    Neste artigo, usaremos dois arquivos:

    • keygenme.c -> código-fonte do keygenme (desafio).
    • keygen.c -> código-fonte de um keygen para o keygenme (solução).

    NOTA: O texto prevê que não conhecemos o código-fonte do desafio. Logo, se você olhar o fonte do keygenme (keygenme.c) ou do keygen proposto (keygen.c) antes de seguir a leitura, tudo perde a graça! Fica tranqüilo que no final dá tudo certo. Aliás, se ainda não deu certo, é porque não chegou ao final. ;)

    Depois de baixar os caras, pode compilar só o keygenme.c, com a sintaxe básica do gcc:

    $ gcc -o keygenme keygenme.c

    O keygenme espera que você passe como argumentos o seu nome e sua chave (esta última você não sabe). Vamos ver se damos sorte:

    $ ./keygenme Fernando FCKGW-90908-30BCD
    Chave inválida

    Claro que eu chutei uma chave qualquer para simular uma tentativa de registro mas não acertei (eu tinha alguma chance de acertar?). Até agora não sabemos nada sobre a chave. Ela pode ser alfanumérica, só numérica, ter ou não hífens, enfim, as possibilidades são infinitas. Desafio é desafio!

    Vamos abrir o binário no EDB. Aliás, uma das facilidades dele é já colocar automaticamente um breakpoint no entrypoint do binário, ou seja, ao abrir um binário, basta mandar o EDB rodá-lo (tecla F9) e ele parará justamente no início da main(). Após fazer isso, você provavelmente verá algo como:

    00000000004006cc: push rbp
    00000000004006cd: mov rbp, rsp
    00000000004006d0: push rbx
    00000000004006d1: sub rsp, 56
    00000000004006d5: mov dword ptr [rbp-52], edi
    00000000004006d8: mov qword ptr [rbp-64], rsi
    00000000004006dc: mov rax, qword ptr [rbp-64]

    NOTA: Ao compilar o keygenme.c na sua máquina, os endereços vão mudar, mas é só ter isso em mente e seguir tranqüilo, fazendo as adaptações.

    Aqui você tem que notar 4 coisas:

    • A seta vermelha (que aparece no EDB) indica qual é a próxima instrução a ser executada pelo processador.
    • A sintaxe do assembly é Intel.
    • Eu estou num sistema de 64-bits, por isso os registradores aparecem como rbp, rbx, rsp, rax, etc. Se fosse um sistema de 32-bits, seria ebp, ebx, esp, eax e por aí vai (só trocar o “r” pelo “e”).
    • De um modo geral, toda função (e a main não é uma exceção), começa com um “push rbp” e termina com um “ret”.

    Não temos tempo para destrinchar linha a linha, mas vamos tratar das linhas mais importates. Aliás, o debugger te ajuda a não precisar de muito conhecimento em assembly para entender o que as linhas fazem.

    Teclando F8 (Step out), passamos para a próxima instrução. Podemos ir teclando F8 calmamente até chegar nas seguintes linhas:

    00000000004006e7: test rax, rax
    00000000004006ea: jz 0x00000000004006fc

    As instruções acima trabalham em conjunto. Em 0x4006e7, o registrador rax é verificado pela instrução test. Se seu valor for zero, a próxima instrução será um salto para 0x4006fc. Se seguirmos dando F8, veremos que este salto vai nos jogar para a linha a seguir:

    00000000004006fc: call 0x00000000004006b4

    Nela tem a instrução call, que é uma chamada de função. Mais um F8 e pimba, o programa encerra. Ok, e para que eu faço isso? Bem, a questão é perguntar-se: por que o programa encerrou? Porque ele chamou a função 0x4006b4. E por que ele a chamou? Porque o salto em 0x4006ea aconteceu. E por que o salto aconteceu? Porque o teste em 0x4006e7 deu verdadeiro. E porque deu verdadeiro? Porque rax estava zerado. Logo, para que o programa não encerre logo no início de sua execução, é preciso ter algo em rax. Para não alongar muito o texto, eu vou dar a cola: rax precisa apontar para um argumento (char *), do contrário, não dá pra começar a brincadeira né? A execução que acompanhamos foi como se tivéssemos feito:

    $ ./keygenme
    Chave inválida

    E pronto. Não passamos argumentos, então não há o que testar. O programa encerra depois de imprimir a mensagem de erro. Vamos corrigir isso.

    No EDB, é preciso colocar os argumentos em Options -> Applications Arguments. Coloquei dois argumentos para chamar o binário como:

    $ ./keygenme Fernando 30303030

    Agora sim a gente passa naquele teste teste em 0x4006ea (e também no teste em 0x4006fa, que testa o segundo argumento). Mas é preciso reabrir o arquivo no EDB depois de configurar argumentos.

    Começando novamente com F8, ao passar por 0x4006ea sem pular, caímos em 0x4006ec. Esta instrução mov copia (pois é, ela não move!) o endereço de memória do primeiro argumento para o registrador rax.

    00000000004006ec: mov rax, qword ptr [rbp-64]

    Ainda bem que agora ele não é nulo. Do contrário, teríamos um belo Segmentation Fault. Por isso do teste antes.;)

    O mesmo acontece em 0x400701, só que para o segundo argumento. Agora muita atenção no trecho abaixo:

    0000000000400709: mov rax, qword ptr [rax]
    000000000040070c: mov rdi, rax
    000000000040070f: call 0x0000000000400588

    Novamente o primeiro argumento (Fernando) é endereçado em rax. E ao passar da call em 0x40070f, o número 8 é posto em rax. Sabendo que o rax é o registrador geralmente usado tanto para passagem de parâmetro quanto para retorno de função, podemos entender que o endereço da string “Fernando” foi passado para a função 0x400588 e esta retornou 8. Consegue ver alguma relação? O que o número 8 tem a ver com a string “Fernando”? Se quiser confirmar sua suspeita, pode mudar este argumento no EDB, reabrir o keygenme e avaliar o novo número de retorno.

    Mais abaixo, segue uma tremenda sacanagem:

    0000000000400717: cmp dword ptr [rbp-40], 3
    000000000040071b: jle 0x0000000000400723
    000000000040071d: cmp dword ptr [rbp-40], 20
    0000000000400721: jle 0x0000000000400728
    0000000000400723: call 0x00000000004006b4

    Em 0x400717 o valor 8 (no caso do meu exemplo) é comparado com 3. Na seqüência vemos um salto jle (Jump if Lower or Equals) para 0x400723. E neste endereço, tem uma call pra 0x4006b4. Lembra desta call? Não foi ela quem encerrou o programa da outra vez? Não podemos cair nela. Sorte que 8 é maior que 3. ;)

    Certo, não saltamos. Agora abaixo:

    000000000040071d: cmp dword ptr [rbp-40], 20
    0000000000400721: jle 0x0000000000400728
    0000000000400723: call 0x00000000004006b4
    0000000000400728: mov eax, dword ptr [rbp-40]

    Outra comparação. Desta vez para ver se o 8 é menor ou igual a 20. Se não for, ele não salta e cai na call maldita novamente, para encerrar o programa. Que conclusões podemos chegar?

    • O programa testa se os dois argumentos existem. Basta que um não exista para que o programa seja encerrado.
    • O 8 visto aqui é o tamanho da string nome (primeiro parâmetro, que no meu exemplo foi “Fernando”).
    • Caso o tamanho da string não esteja entre 4 e 20 caracteres, o programa encerra.

    Seguindo com F8, chegamos neste bloco:

    000000000040075d: movsxd rax, rbx
    0000000000400760: add rax, qword ptr [rbp-32]
    0000000000400764: movzx eax, byte ptr [rax]
    0000000000400767: movsx eax, al
    000000000040076a: mov edi, eax
    000000000040076c: call 0x00000000004005a8
    0000000000400771: test eax, eax
    0000000000400773: jnz 0x000000000040077a
    0000000000400775: call 0x00000000004006b4
    000000000040077a: add ebx, 1
    000000000040077d: cmp ebx, dword ptr [rbp-40]
    0000000000400780: jl 0x000000000040075d

    Se você não conhece assembly, pode ser que não esteja claro, mas o debugger com certeza vai te entregar que isso é um loop determinado (um for). Vou deixar essa análise de lado, mas quem não conhece pode olhar o fonte em C depois e tentar identificá-lo aqui.

    Mais abaixo, outro loop:

    0000000000400789: movsxd rax, rbx
    000000000040078c: add rax, qword ptr [rbp-32]
    0000000000400790: movzx eax, byte ptr [rax]
    0000000000400793: movsx eax, al
    0000000000400796: add eax, 10
    0000000000400799: add dword ptr [rbp-36], eax
    000000000040079c: add ebx, 1
    000000000040079f: cmp ebx, dword ptr [rbp-40]
    00000000004007a2: jl 0x0000000000400789

    Esse já é mais simples. Pelo debugger você vai perceber que ele pega o valor ASCII de cada caracter do primeiro parâmetro e soma com 10. E vai somando esses resultados também (em memória, no endereço [rbp-36]). Quando este loop acabar, o endereço [rbp-36] conterá a soma em ASCII de todos os caracteres da string do nome somados, mais o resultado de 10 vezes o número de caracteres da string. Ou seja, se o nome fosse “ABCDE”, teríamos:

    A -> 65
    B -> 66
    C -> 67
    D -> 68
    E -> 69
    
    65+10 + 66+10 + 67+10 + 68+10 + 69+10 = 385

    Dá no mesmo que:

    65 + 66 + 67 + 68 + 69 + 10 * 5 = 385

    E esta é a lógica do programa. Ele pega o nome de usuário inserido, aumenta os valores de cada caracter em 10 unidades e depois os soma. O resultado é a chave para o nome de usuário inserido. Além disso, há as restrições de tamanho de nome e mais algumas que precisam ser implementadas no keygen.

    Agora é só fazer o keygen (lembrando que propus um no começo do artigo). Se quiser brincar, pode escrever um programa na sua linguagem preferida que receba um nome de usuário de acordo com as regras impostas pelo keygenme e gere uma chave válida para este usuário.;)


    User Feedback

    Join the conversation

    You can post now and register later. If you have an account, sign in now to post with your account.

    Guest

  • Similar Content

    • By Edinho Sousa
      Os compiladores são ferramentas muito úteis e importantes no mundo da programação e desenvolvimento. A função básica dos compiladores é pegar uma linguagem de "alto nível" (linguagem com maior nível de abstração do hardware) e produzir um código semanticamente equivalente em "baixo nível". A velocidade de execução do código compilado é uma vantagem que se destaca, tendo em vista que o compilador faz otimizações no processo de compilação. Verificações de erros sintáticos e semânticos são outras funcionalidades também executadas pelo compilador.
      Por que criar um compilador?
      Além dos motivos mencionados anteriormente, a forma mais simples e rápida de entender como os compiladores funcionam é criando um. Neste tutorial iremos criar um compilador simples, porém abordando os principais conceitos da compilação de forma teórica e prática.
      Para seguir esse tutorial será necessário o conhecimento de algoritmo e no mínimo uma linguagem de programação. Neste artigo estarei utilizando a linguagem C.
      Antes de começarmos a criação do projeto, vamos organizar o nosso projeto:
      Criaremos uma linguagem que trabalha com números inteiros e reais; Utilizaremos condições (if, else, etc); Utilizaremos expressões aritméticas e relacionais; Etapas da compilação
      As etapas que um compilador executa são: Análise léxica, Análise sintática, análise semântica, otimizador de código e gerador de código objeto. Alguns compiladores tem uma estrutura bem mais complexa, dependendo da linguagem a ser compilada:

      Nosso projeto terá as seguintes etapas: análise léxica, análise sintática, análise semântica e gerador de código. O gerador de código vai gerar um bytecode para uma máquina virtual que também vamos implementar. Bytecodes são instruções para uma máquina virtual, como mover um valor para a memória ou para um registrador, por exemplo. Abaixo podemos ver um trecho de código em Python e seus respectivos bytecodes:
      def soma(): print(10 + 10)  
      0 LOAD_GLOBAL 0 (print) 2 LOAD_CONST 1 (20) 4 CALL_FUNCTION 1 6 POP_TOP 8 LOAD_CONST 0 (None) 10 RETURN_VALUE No final desta série estaremos executando o seguinte código:
      INIT VAR max := 10 VAR num INPUT num IF (num < max) INIT PRINT 0 END ELSE INIT PRINT 1 END END Análise Léxica
      A análise léxica consiste em pegar cada caractere de uma linguagem e identificar os padrões da linguagem. Exemplo:
      int a = 10 Aqui podemos identificar os seguintes padrões:
      int é uma palavra reservada do compilador; a é um identificador/variável; = é um sinal de atribuição; 10 é um número inteiro; Ao realizar esse processo estamos identificando os lexemas, que são pedaços de uma string (texto), reconhecidos pelo analisador léxico. Os tokens são um par constituído de um nome e um valor de atributo, sendo este último opcional:
      <tipo, valor> Onde:
      tipo como o nome já diz seria o tipo do token. valor é o valor de um token. Alguns tokens não utilizam este campo. Representação da análise léxica:

      Para uma entrada como VAR num := 100 + 10 obtemos os seguintes tokens:
      <PC_VAR> <ID, num> <OP_ATR> <T_INT, 100> <OP_MAIS> <T_INT, 10> Onde:
      <PC_VAR> representa a palavra chave VAR; <ID, num> representa um identificador (variável ou função) tendo o valor num; <OP_ART> representa o operador de atribuição =; <OP_MAIS> representa o operador aritmético mais (+); <T_INT, 100>, <T_INT, 10> representa um inteiro com o valor 100 e 10 respectivamente; Não se esqueça que os tipos de token são definidos por você!
      Usarei o gcc como compilador C e o vscode como editor. Iremos começar de uma forma simples,  melhorando tudo aos poucos, vamos nessa!
      Essa é a estrutura de pastas do nosso projeto. Temos uma pasta para os headers, uma pasta src para o código fonte e a pasta exe, que terá o executável:

      Escreva o texto seguinte no arquivo teste.txt:
      INIT PRINT 1 + 2 * 3 END
      include/lex.h - Aqui simplesmente criamos um módulo para tratar da análise léxica e definimos a função que retorna um token:
      #ifndef art_lex_h #define art_lex_h void proximo_token(); #endif src/lex.c: Esta é nossa função inicial que lê cada caractere e mostra na console. Se o caractere for EOF, significa que não há mais caracteres no arquivo (fim de arquivo) e então paramos o loop:
      #include <string.h> #include <ctype.h> #include "glob.h" #include "lex.h" // variável que passará por cada caractere do arquivo static int c; void proximo_token() { while (1) { c = fgetc(file); if (c == EOF) break; else printf("%c", c); } } includes/glob.h: Este outro arquivo serve para algumas definições globais (que vamos usar em mais de um arquivo). Definimos os tipos dos tokens, um enum para representar o token e uma struct com os campos tipo e val:
      #ifndef art_glob_h #define art_glob_h #include <stdio.h> #include <stdlib.h> FILE *file; // linha atual static int linha = 1; // tipos de tokens enum { // palavras chave PC_INIT, PC_END, PC_PRINT, PC_INPUT, PC_VAR, PC_IF, PC_ELSE, // numeros T_INT, // operadores OP_MAIS, OP_MENOS, OP_MULT, OP_DIVI, // ( ) := < > <= >= = T_LPARENT, T_RPARENT, T_ATRIB, T_MENOR, T_MAIOR, T_MENOR_I, T_MAIOR_I, T_IGUAL, // identificador ID }; typedef struct { int tipo; int val; } Token; Token tok; #endif src/main.c: Na função main iremos tentar abrir um arquivo. Caso haja algum erro o programa sairá mostrando a mensagem de erro. Caso contrário, leremos todos os caracteres do arquivo teste.txt. Vamos ver se funciona:
      #include <stdlib.h> #include "lex.h" #include "glob.h" int main(int argc, char *argv[]) { // abrir o arquivo file = fopen(argv[1], "r"); if (file == NULL) { printf("Erro ao abrir o arquivo"); exit(EXIT_FAILURE); } proximo_token(); fclose(file); return EXIT_SUCCESS; // ou return 0 } Para facilitar o processo de compilação usaremos o seguinte Makefile:
      all: gcc -c src/lex.c -I includes -o exe/lex.o gcc src/main.c exe/*.o -I includes -o exe/main rm -r exe/*.o *Se você estiver em um ambiente Windows saiba que o comando rm -r exe/*.o  não funcionará.
      Ao executar o Makefile teremos na pasta exe o arquivo compilado. Ao executarmos teremos a seguinte saída:
      INIT PRINT 1 + 2 * 3 END Perfeito! Por agora vamos ignorar espaços em branco, tabulação e quebra de linha.
      Criaremos agora uma função que vai criar um token. Por enquanto ela irá apenas mostrar na saída algo como <’+’, 0> <’INIT’, 0>, mas depois vamos mudar isso.
      lex.c: Aqui estamos somando 1 na variável linha para uso posterior em caso de nosso compilador ache um caractere que não existe em nossa linguagem (como um “$”, por exemplo):
      void makeToken(char *nome, int val) // mostrar o token { printf("<%s, %d>", nome, val); } void voltaPonteiro() // volta um caracter se necessário { if (c != EOF) fseek(file, ftell(file)-1, SEEK_SET); } void proximo_token() { // após o if else if (c == ' ' || c == '\t') continue; else if (c == '\n') { linha++; continue; } } No código acima temos uma função voltaPonteiro, que é responsável por voltar um caractere no arquivo. Em alguns casos vamos ter que ver o caractere a frente e depois voltar o caractere quando estivermos analisando uma palavra chave. Enquanto o caractere for alfanumérico o ponteiro avança.
      Para facilitar o entendimento vamos utilizar a imagem abaixo como exemplo. Aqui reconhecemos a palavra num e paramos no caractere =, ou seja, reconhecemos o token <ID, num>. Quando vamos continuar o processo iniciamos do =, isto é, o próximo caractere é o espaço, seguido do número 1 e assim por diante. Tendo em vista que = é um caractere diferente do que estaríamos esperando iremos esquece-lo e então voltaremos um caractere parando assim no m.

      lex.c: vamos reconhecer operadores aritméticos como mais (+), menos (-), multiplicação (*) e divisão (/):
      void proximo_token() { // codigo anterior else if (c == '+') makeToken("+", 0); else if (c == '-') makeToken("-", 0); else if (c == '*') makeToken("*", 0); else if (c == '/') makeToken("/", 0); // codigo else Ao compilar o código e executar teremos algo como:
      $ ./exe/main.exe teste.txt INITPRINT1<+, 0>2<*, 0>3END lex.c: Agora vamos reconhecer os demais números, palavras, parênteses, etc:
      else if (c == '+') { makeToken("+", 0); } else if (c == '-') { makeToken("-", 0); } else if (c == '*'){ makeToken("*", 0); } else if (c == '/') { makeToken("/", 0); } else if (c == '(') { makeToken("(", 0); } else if (c == ')') { makeToken(")", 0); } else if (c == ':') { c = fgetc(file); // pega o próximo caractere if (c == '=') // se for '=' sabemos que é o token ':=' makeToken(":=", 0); } else if (c == '<') { c = fgetc(file); // pega o próximo caractere if (c == '=') // se for '=' sabemos que é o token '<=' makeToken("<=", 0); else makeToken("<", 0); } else if (c == '>') { c = fgetc(file); if (c == '=') makeToken(">=", 0); else makeToken(">", 0); } else if (c == '=') { makeToken("=", 0); } else if (isdigit(c)) { numero(); } else if (isalpha(c)) { palavra(); } else { printf("O caracter '%c' na linha %d nao reconhecido.\n", c, linha); exit(EXIT_FAILURE); } lex.c: Temos duas novas funções, são elas palavra e numero:
      void palavra() { char palavra[100] = ""; int pos = 0; while (isalnum(c)) { palavra[pos++] = c; c = fgetc(file); } voltaPonteiro(); if (strcmp(palavra, "INIT") == 0) makeToken("INIT", 0); else if (strcmp(palavra, "PRINT") == 0) makeToken("PRINT", 0); else if (strcmp(palavra, "INPUT") == 0) makeToken("INPUT", 0); else if (strcmp(palavra, "VAR") == 0) makeToken("VAR", 0); else if (strcmp(palavra, "IF") == 0) makeToken("IF", 0); else if (strcmp(palavra, "ELSE") == 0) makeToken("ELSE", 0); else if (strcmp(palavra, "END") == 0) makeToken("END", 0); else makeToken("ID", 0); } Não é a função mais otimizada que você já viu, mas funciona:
      void numero() { int k = 0; while (isdigit(c)) { k = k * 10 + c - '0'; c = fgetc(file); } voltaPonteiro(); makeToken("T_INT", k); } Testamos o código agora:
      $ ./exe/main teste.txt <INIT, 0><PRINT, 0><T_INT, 1><+, 0><T_INT, 2><*, 0><T_INT, 3><END, 0> Olha só, reconhecemos a maior parte dos tokens de nossa linguagem! Agora que tal mais um teste utilizando outro teste.txt?
      INIT VAR max := 10 VAR num INPUT num IF (num < max) INIT PRINT 0 END ELSE INIT PRINT 1 END END  
      $ ./exe/main teste.txt <INIT, 0><VAR, 0><END, 0><:=, 0><=, 0><T_INT, 10><VAR, 0><END, 0><INPUT, 0><END, 0><IF, 0> <(, 0><END, 0><<, 0><END, 0><), 0><INIT, 0><PRINT, 0><T_INT, 0><END, 0><ELSE, 0><INIT, 0> <PRINT, 0><T_INT, 1><END, 0><END, 0> Na próxima parte vamos fazer algumas alterações no analisador léxico e depois daremos início ao analisador sintático. Até lá. 🙂
    • By emilio.simoni
      Boa noite pessoal,
      estamos com vagas remotas para programador c++ para o time de ciber segurança da PSafe, a vaga é para atuar diretamente no nosso motor anti ransomware, criando novas features, acompanhando a evolução das ameaças e ajudando a criar formas comportamentais de proteção.
      Necessario:
      Mínimo de 3 anos em c/c++ com experiência em arquitetura de sistema operacional Experiência com Poco ou Boost, gtest ou outro framework de unit testing Conhecimento de funcionamento de malware, em especial ransomware Experiencia com windbg Diferenciais:
      Experiência com programação em kernel windows (mini filters, wfp, ...) ou mac Experiência com engenharia reversa Experiência com machine learning (scikit, tensorflow, xgb) Interessados emilio.simoni@psafe.com
    • By emilio.simoni
      Boa noite pessoal,
      estamos com vagas remotas para programador python fullstack com experiencia em AWS para atuar na area de arquitetura, nos micro serviços que se comunicam com nossos sdks de proteção de endpoint.
      Necessário:
      Mínimo de 3 anos de experiência em web services(fullstack) e 2 anos de experiência com ambiente aws Experiência com sanic ou fastapi na implementação de micro serviços Experiência com elasticsearch e kibana Experiência com bancos relacionais(postgree), key value(redis) e nosql(mongodb) Experiência com unit test, pylist ou outras ferramentas de qualidade Experiência com docker Experiência com elastic APM ou outros sistemas de monitoramento Difrenciais:
      Experiência com kafka Experiência com serviços de alta demanda(nossos endpoints chegama receber 5 mil requests por segundo) Experiência com front-end  
      Interessados emilio.simoni@psafe.com
    • By Fernando Mercês
      Conci, Aura
      Javascript para construção de páginas de Web / Aura Conci; João Sérgio Assis - Niterói, RJ: Editora da UFF, 2012.
      p. : 23 cm. — (Coleção Didáticos EdUFF, 2004)
      Bibliografia. p. 229
      ISBN 978-85-228-0535-8
      1. Javascript. 2. Construção de páginas de Web. I. Conci, Aura. II. Assis, João Sérgio. III Universidade Federal Fluminense. IV. Título
      CDD 370”
      Excerpt From: Aura Conci e João Sérgio Assis. “Javascript para construção de páginas de Web.” Apple Books. 
    • By Fernando Mercês
      Programação para leigos com Raspberry Pi / Elivelto Ebermam... [et al.]. – Vitória, ES : Edifes ; João Pessoa, PB : Editora IFPB, 2017.
      290 p. : il. ; 21 cm.
      Inclui bibliografia.
      ISBN 97885________(broch.). ISBN 97885________(e-book).
      1. Raspberry Pi (Computador) – Microcomputadores. I. Título.
      Autores:
      Elivelto Ebermam
      Guilherme Moraes Pesente
      Renan Osório Rios
      Igor Carlos Pulini
×
×
  • Create New...